From 5f80ad6da8946ea22b80e67b8283d32fe383eade Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 18:20:36 +0000 Subject: [PATCH 01/94] fix: chat completion with parallel tool call (#7634) --- codex-rs/codex-api/src/sse/chat.rs | 204 +++++++++++++++++++++++------ 1 file changed, 163 insertions(+), 41 deletions(-) diff --git a/codex-rs/codex-api/src/sse/chat.rs b/codex-rs/codex-api/src/sse/chat.rs index 5e48c57bd85..21adfa571a2 100644 --- a/codex-rs/codex-api/src/sse/chat.rs +++ b/codex-rs/codex-api/src/sse/chat.rs @@ -10,6 +10,7 @@ use eventsource_stream::Eventsource; use futures::Stream; use futures::StreamExt; use std::collections::HashMap; +use std::collections::HashSet; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::Instant; @@ -41,12 +42,17 @@ pub async fn process_chat_sse( #[derive(Default, Debug)] struct ToolCallState { + id: Option, name: Option, arguments: String, } - let mut tool_calls: HashMap = HashMap::new(); - let mut tool_call_order: Vec = Vec::new(); + let mut tool_calls: HashMap = HashMap::new(); + let mut tool_call_order: Vec = Vec::new(); + let mut tool_call_order_seen: HashSet = HashSet::new(); + let mut tool_call_index_by_id: HashMap = HashMap::new(); + let mut next_tool_call_index = 0usize; + let mut last_tool_call_index: Option = None; let mut assistant_item: Option = None; let mut reasoning_item: Option = None; let mut completed_sent = false; @@ -149,15 +155,40 @@ pub async fn process_chat_sse( if let Some(tool_call_values) = delta.get("tool_calls").and_then(|c| c.as_array()) { for tool_call in tool_call_values { - let id = tool_call - .get("id") - .and_then(|i| i.as_str()) - .map(str::to_string) - .unwrap_or_else(|| format!("tool-call-{}", tool_call_order.len())); - - let call_state = tool_calls.entry(id.clone()).or_default(); - if !tool_call_order.contains(&id) { - tool_call_order.push(id.clone()); + let mut index = tool_call + .get("index") + .and_then(serde_json::Value::as_u64) + .map(|i| i as usize); + + let mut call_id_for_lookup = None; + if let Some(call_id) = tool_call.get("id").and_then(|i| i.as_str()) { + call_id_for_lookup = Some(call_id.to_string()); + if let Some(existing) = tool_call_index_by_id.get(call_id) { + index = Some(*existing); + } + } + + if index.is_none() && call_id_for_lookup.is_none() { + index = last_tool_call_index; + } + + let index = index.unwrap_or_else(|| { + while tool_calls.contains_key(&next_tool_call_index) { + next_tool_call_index += 1; + } + let idx = next_tool_call_index; + next_tool_call_index += 1; + idx + }); + + let call_state = tool_calls.entry(index).or_default(); + if tool_call_order_seen.insert(index) { + tool_call_order.push(index); + } + + if let Some(id) = tool_call.get("id").and_then(|i| i.as_str()) { + call_state.id.get_or_insert_with(|| id.to_string()); + tool_call_index_by_id.entry(id.to_string()).or_insert(index); } if let Some(func) = tool_call.get("function") { @@ -171,6 +202,8 @@ pub async fn process_chat_sse( call_state.arguments.push_str(arguments); } } + + last_tool_call_index = Some(index); } } } @@ -224,13 +257,25 @@ pub async fn process_chat_sse( .await; } - for call_id in tool_call_order.drain(..) { - let state = tool_calls.remove(&call_id).unwrap_or_default(); + for index in tool_call_order.drain(..) { + let Some(state) = tool_calls.remove(&index) else { + continue; + }; + tool_call_order_seen.remove(&index); + let ToolCallState { + id, + name, + arguments, + } = state; + let Some(name) = name else { + debug!("Skipping tool call at index {index} because name is missing"); + continue; + }; let item = ResponseItem::FunctionCall { id: None, - name: state.name.unwrap_or_default(), - arguments: state.arguments, - call_id: call_id.clone(), + name, + arguments, + call_id: id.unwrap_or_else(|| format!("tool-call-{index}")), }; let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; } @@ -335,6 +380,59 @@ mod tests { out } + #[tokio::test] + async fn concatenates_tool_call_arguments_across_deltas() { + let delta_name = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "id": "call_a", + "index": 0, + "function": { "name": "do_a" } + }] + } + }] + }); + + let delta_args_1 = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "function": { "arguments": "{ \"foo\":" } + }] + } + }] + }); + + let delta_args_2 = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "function": { "arguments": "1}" } + }] + } + }] + }); + + let finish = json!({ + "choices": [{ + "finish_reason": "tool_calls" + }] + }); + + let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]); + let events = collect_events(&body).await; + assert_matches!( + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }), + ResponseEvent::Completed { .. } + ] if call_id == "call_a" && name == "do_a" && arguments == "{ \"foo\":1}" + ); + } + #[tokio::test] async fn emits_multiple_tool_calls() { let delta_a = json!({ @@ -367,50 +465,74 @@ mod tests { let body = build_body(&[delta_a, delta_b, finish]); let events = collect_events(&body).await; - assert_eq!(events.len(), 3); - assert_matches!( - &events[0], - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }) - if call_id == "call_a" && name == "do_a" && arguments == "{\"foo\":1}" - ); - assert_matches!( - &events[1], - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }) - if call_id == "call_b" && name == "do_b" && arguments == "{\"bar\":2}" + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }), + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }), + ResponseEvent::Completed { .. } + ] if call_a == "call_a" && name_a == "do_a" && args_a == "{\"foo\":1}" && call_b == "call_b" && name_b == "do_b" && args_b == "{\"bar\":2}" ); - assert_matches!(events[2], ResponseEvent::Completed { .. }); } #[tokio::test] - async fn concatenates_tool_call_arguments_across_deltas() { - let delta_name = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "id": "call_a", - "function": { "name": "do_a" } - }] + async fn emits_tool_calls_for_multiple_choices() { + let payload = json!({ + "choices": [ + { + "delta": { + "tool_calls": [{ + "id": "call_a", + "index": 0, + "function": { "name": "do_a", "arguments": "{}" } + }] + }, + "finish_reason": "tool_calls" + }, + { + "delta": { + "tool_calls": [{ + "id": "call_b", + "index": 0, + "function": { "name": "do_b", "arguments": "{}" } + }] + }, + "finish_reason": "tool_calls" } - }] + ] }); - let delta_args_1 = json!({ + let body = build_body(&[payload]); + let events = collect_events(&body).await; + assert_matches!( + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }), + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }), + ResponseEvent::Completed { .. } + ] if call_a == "call_a" && name_a == "do_a" && args_a == "{}" && call_b == "call_b" && name_b == "do_b" && args_b == "{}" + ); + } + + #[tokio::test] + async fn merges_tool_calls_by_index_when_id_missing_on_subsequent_deltas() { + let delta_with_id = json!({ "choices": [{ "delta": { "tool_calls": [{ + "index": 0, "id": "call_a", - "function": { "arguments": "{ \"foo\":" } + "function": { "name": "do_a", "arguments": "{ \"foo\":" } }] } }] }); - let delta_args_2 = json!({ + let delta_without_id = json!({ "choices": [{ "delta": { "tool_calls": [{ - "id": "call_a", + "index": 0, "function": { "arguments": "1}" } }] } @@ -423,7 +545,7 @@ mod tests { }] }); - let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]); + let body = build_body(&[delta_with_id, delta_without_id, finish]); let events = collect_events(&body).await; assert_matches!( &events[..], From d08efb1743a93fb52e0f2b4e2f060de7d89326ba Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 5 Dec 2025 10:40:15 -0800 Subject: [PATCH 02/94] Wire `with_remote_overrides` to construct model families (#7621) - This PR wires `with_remote_overrides` and make the `construct_model_families` an async function - Moves getting model family a level above to keep the function `sync` - Updates the tests to local, offline, and `sync` helper for model families --- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/codex.rs | 86 +++++++++++-------- .../core/src/openai_models/models_manager.rs | 9 +- codex-rs/core/src/sandboxing/assessment.rs | 4 +- .../core/tests/chat_completions_payload.rs | 6 +- codex-rs/core/tests/chat_completions_sse.rs | 5 +- codex-rs/core/tests/common/Cargo.toml | 2 +- codex-rs/core/tests/responses_headers.rs | 16 ++-- codex-rs/core/tests/suite/client.rs | 4 +- codex-rs/core/tests/suite/prompt_caching.rs | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 21 ++++- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/chatwidget.rs | 21 +++-- codex-rs/tui/src/chatwidget/tests.rs | 4 + codex-rs/tui/src/history_cell.rs | 67 ++++++--------- 16 files changed, 144 insertions(+), 105 deletions(-) diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8a329d06728..f24cc9bc67f 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -90,6 +90,7 @@ wildmatch = { workspace = true } [features] deterministic_process_ids = [] +test-support = [] [target.'cfg(target_os = "linux")'.dependencies] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d35f95e4238..89435ee6d0b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -14,6 +14,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::exec_policy::load_exec_policy_for_features; use crate::features::Feature; use crate::features::Features; +use crate::openai_models::model_family::ModelFamily; use crate::openai_models::models_manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; @@ -398,35 +399,39 @@ pub(crate) struct SessionSettingsUpdate { } impl Session { + fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { + let config = session_configuration.original_config_do_not_use.clone(); + let mut per_turn_config = (*config).clone(); + per_turn_config.model = session_configuration.model.clone(); + per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; + per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; + per_turn_config.features = config.features.clone(); + per_turn_config + } + + #[allow(clippy::too_many_arguments)] fn make_turn_context( auth_manager: Option>, - models_manager: Arc, otel_event_manager: &OtelEventManager, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, + mut per_turn_config: Config, + model_family: ModelFamily, conversation_id: ConversationId, sub_id: String, ) -> TurnContext { - let config = session_configuration.original_config_do_not_use.clone(); - let features = &config.features; - let mut per_turn_config = (*config).clone(); - per_turn_config.model = session_configuration.model.clone(); - per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; - per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; - per_turn_config.features = features.clone(); - let model_family = - models_manager.construct_model_family(&per_turn_config.model, &per_turn_config); if let Some(model_info) = get_model_info(&model_family) { per_turn_config.model_context_window = Some(model_info.context_window); } let otel_event_manager = otel_event_manager.clone().with_model( session_configuration.model.as_str(), - session_configuration.model.as_str(), + model_family.slug.as_str(), ); + let per_turn_config = Arc::new(per_turn_config); let client = ModelClient::new( - Arc::new(per_turn_config.clone()), + per_turn_config.clone(), auth_manager, model_family.clone(), otel_event_manager, @@ -439,7 +444,7 @@ impl Session { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - features, + features: &per_turn_config.features, }); TurnContext { @@ -452,14 +457,14 @@ impl Session { user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy, sandbox_policy: session_configuration.sandbox_policy.clone(), - shell_environment_policy: config.shell_environment_policy.clone(), + shell_environment_policy: per_turn_config.shell_environment_policy.clone(), tools_config, final_output_json_schema: None, - codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), exec_policy: session_configuration.exec_policy.clone(), truncation_policy: TruncationPolicy::new( - &per_turn_config, + per_turn_config.as_ref(), model_family.truncation_policy, ), } @@ -545,7 +550,9 @@ impl Session { }); } - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = models_manager + .construct_model_family(&config.model, &config) + .await; // todo(aibrahim): why are we passing model here while it can change? let otel_event_manager = OtelEventManager::new( conversation_id, @@ -768,12 +775,19 @@ impl Session { session_configuration }; + let per_turn_config = Self::build_per_turn_config(&session_configuration); + let model_family = self + .services + .models_manager + .construct_model_family(&per_turn_config.model, &per_turn_config) + .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), - Arc::clone(&self.services.models_manager), &self.services.otel_event_manager, session_configuration.provider.clone(), &session_configuration, + per_turn_config, + model_family, self.conversation_id, sub_id, ); @@ -1907,7 +1921,8 @@ async fn spawn_review_thread( let review_model_family = sess .services .models_manager - .construct_model_family(&model, &config); + .construct_model_family(&model, &config) + .await; // For reviews, disable web_search and view_image regardless of global settings. let mut review_features = sess.features.clone(); review_features @@ -2812,15 +2827,12 @@ mod tests { fn otel_event_manager( conversation_id: ConversationId, config: &Config, - models_manager: &ModelsManager, + model_family: &ModelFamily, ) -> OtelEventManager { OtelEventManager::new( conversation_id, config.model.as_str(), - models_manager - .construct_model_family(&config.model, config) - .slug - .as_str(), + model_family.slug.as_str(), None, Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), @@ -2843,9 +2855,6 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let otel_event_manager = - otel_event_manager(conversation_id, config.as_ref(), &models_manager); - let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), model: config.model.clone(), @@ -2862,6 +2871,11 @@ mod tests { exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; + let per_turn_config = Session::build_per_turn_config(&session_configuration); + let model_family = + ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let otel_event_manager = + otel_event_manager(conversation_id, config.as_ref(), &model_family); let state = SessionState::new(session_configuration.clone()); @@ -2875,16 +2889,17 @@ mod tests { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: auth_manager.clone(), otel_event_manager: otel_event_manager.clone(), - models_manager: models_manager.clone(), + models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), }; let turn_context = Session::make_turn_context( Some(Arc::clone(&auth_manager)), - models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, + per_turn_config, + model_family, conversation_id, "turn_id".to_string(), ); @@ -2922,9 +2937,6 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let otel_event_manager = - otel_event_manager(conversation_id, config.as_ref(), &models_manager); - let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), model: config.model.clone(), @@ -2941,6 +2953,11 @@ mod tests { exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; + let per_turn_config = Session::build_per_turn_config(&session_configuration); + let model_family = + ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let otel_event_manager = + otel_event_manager(conversation_id, config.as_ref(), &model_family); let state = SessionState::new(session_configuration.clone()); @@ -2954,16 +2971,17 @@ mod tests { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), - models_manager: models_manager.clone(), + models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), }; let turn_context = Arc::new(Session::make_turn_context( Some(Arc::clone(&auth_manager)), - models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, + per_turn_config, + model_family, conversation_id, "turn_id".to_string(), )); diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index fd0fea362d4..22edf04ffe5 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -61,7 +61,14 @@ impl ModelsManager { Ok(models) } - pub fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { + pub async fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { + find_family_for_model(model) + .with_config_overrides(config) + .with_remote_overrides(self.remote_models.read().await.clone()) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn construct_model_family_offline(model: &str, config: &Config) -> ModelFamily { find_family_for_model(model).with_config_overrides(config) } diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs index 8a34a933288..b7a9c952d19 100644 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ b/codex-rs/core/src/sandboxing/assessment.rs @@ -126,7 +126,9 @@ pub(crate) async fn assess_command( output_schema: Some(sandbox_assessment_schema()), }; - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = models_manager + .construct_model_family(&config.model, &config) + .await; let child_otel = parent_otel.with_model(config.model.as_str(), model_family.slug.as_str()); diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index db1407455a4..1449a833dae 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -1,8 +1,6 @@ use std::sync::Arc; use codex_app_server_protocol::AuthMode; -use codex_core::AuthManager; -use codex_core::CodexAuth; use codex_core::ContentItem; use codex_core::LocalShellAction; use codex_core::LocalShellExecAction; @@ -73,9 +71,7 @@ async fn run_request(input: Vec) -> Value { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 0351263ebba..fe7ec58945a 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -1,6 +1,5 @@ use assert_matches::assert_matches; use codex_core::AuthManager; -use codex_core::openai_models::models_manager::ModelsManager; use std::sync::Arc; use tracing_test::traced_test; @@ -12,6 +11,7 @@ use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; use codex_core::WireApi; +use codex_core::openai_models::models_manager::ModelsManager; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; use codex_protocol::models::ReasoningItemContent; @@ -74,8 +74,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let auth_mode = auth_manager.get_auth_mode(); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 75af1b4dd69..09da4bc7016 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -11,7 +11,7 @@ path = "lib.rs" anyhow = { workspace = true } assert_cmd = { workspace = true } base64 = { workspace = true } -codex-core = { workspace = true } +codex-core = { workspace = true, features = ["test-support"] } codex-protocol = { workspace = true } notify = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 02423f3dfdc..d79de721671 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -65,9 +65,7 @@ async fn responses_stream_includes_subagent_header_on_review() { let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), @@ -157,9 +155,7 @@ async fn responses_stream_includes_subagent_header_on_other() { let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, @@ -250,16 +246,16 @@ async fn responses_respects_model_family_overrides_from_config() { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let model_family = models_manager.construct_model_family(&config.model, &config); + let auth_mode = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")).get_auth_mode(); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), - auth_manager.get_auth_mode(), + auth_mode, false, "test".to_string(), ); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a508ae6817c..8b3d63a4140 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1015,11 +1015,9 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; let config = Arc::new(config); - + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 95f2d35cd7b..2bc71298d43 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -137,6 +137,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { let base_instructions = conversation_manager .get_models_manager() .construct_model_family(&config.model, &config) + .await .base_instructions .clone(); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 248205c4278..4e5fad06b4b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -105,6 +105,7 @@ arboard = { workspace = true } [dev-dependencies] +codex-core = { workspace = true, features = ["test-support"] } assert_matches = { workspace = true } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 28535e53662..10d11d05352 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -302,7 +302,10 @@ impl App { }; let enhanced_keys_supported = tui.enhanced_keys_supported(); - + let model_family = conversation_manager + .get_models_manager() + .construct_model_family(&config.model, &config) + .await; let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { @@ -317,6 +320,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, + model_family, }; ChatWidget::new(init, conversation_manager.clone()) } @@ -343,6 +347,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, + model_family, }; ChatWidget::new_from_existing( init, @@ -481,6 +486,11 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + let model_family = self + .server + .get_models_manager() + .construct_model_family(&self.config.model, &self.config) + .await; match event { AppEvent::NewSession => { let summary = session_summary( @@ -500,6 +510,7 @@ impl App { feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, + model_family, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { @@ -549,6 +560,7 @@ impl App { feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, + model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new_from_existing( init, @@ -677,7 +689,12 @@ impl App { self.on_update_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { - self.chat_widget.set_model(&model); + let model_family = self + .server + .get_models_manager() + .construct_model_family(&model, &self.config) + .await; + self.chat_widget.set_model(&model, model_family); self.config.model = model; } AppEvent::OpenReasoningPopup { model } => { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 2f59872bced..ca9de52e2a5 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -340,6 +340,7 @@ impl App { let session_configured = new_conv.session_configured; let init = crate::chatwidget::ChatWidgetInit { config: cfg, + model_family: self.chat_widget.get_model_family(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), initial_prompt: None, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3b57ce9d14..41fe181b33b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -11,6 +11,7 @@ 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::openai_models::model_family::ModelFamily; use codex_core::openai_models::models_manager::ModelsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; @@ -261,6 +262,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) skills: Option>, pub(crate) is_first_run: bool, + pub(crate) model_family: ModelFamily, } #[derive(Default)] @@ -277,6 +279,7 @@ pub(crate) struct ChatWidget { bottom_pane: BottomPane, active_cell: Option>, config: Config, + model_family: ModelFamily, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -465,15 +468,13 @@ impl ChatWidget { } fn on_agent_reasoning_final(&mut self) { + let reasoning_summary_format = self.get_model_family().reasoning_summary_format; // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - let model_family = self - .models_manager - .construct_model_family(&self.config.model, &self.config); if !self.full_reasoning_buffer.is_empty() { let cell = history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), - &model_family, + reasoning_summary_format, ); self.add_boxed_history(cell); } @@ -647,6 +648,9 @@ impl ChatWidget { self.stream_controller = None; self.maybe_show_pending_rate_limit_prompt(); } + pub(crate) fn get_model_family(&self) -> ModelFamily { + self.model_family.clone() + } fn on_error(&mut self, message: String) { self.finalize_turn(); @@ -1249,6 +1253,7 @@ impl ChatWidget { feedback, skills, is_first_run, + model_family, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1270,6 +1275,7 @@ impl ChatWidget { }), active_cell: None, config: config.clone(), + model_family, auth_manager, models_manager, session_header: SessionHeader::new(config.model), @@ -1329,6 +1335,7 @@ impl ChatWidget { models_manager, feedback, skills, + model_family, .. } = common; let mut rng = rand::rng(); @@ -1353,6 +1360,7 @@ impl ChatWidget { }), active_cell: None, config: config.clone(), + model_family, auth_manager, models_manager, session_header: SessionHeader::new(config.model), @@ -1785,7 +1793,7 @@ impl ChatWidget { EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { self.on_agent_reasoning_delta(text); - self.on_agent_reasoning_final() + self.on_agent_reasoning_final(); } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TaskStarted(_) => self.on_task_started(), @@ -2843,9 +2851,10 @@ impl ChatWidget { } /// Set the model in the widget's config copy. - pub(crate) fn set_model(&mut self, model: &str) { + pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { self.session_header.set_model(model); self.config.model = model.to_string(); + self.model_family = model_family; } pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6f2e656a5e4..229e075e7ff 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -10,6 +10,7 @@ use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -345,6 +346,7 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config(); + let model_family = ModelsManager::construct_model_family_offline(&cfg.model, &cfg); let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( "test", ))); @@ -361,6 +363,7 @@ async fn helpers_are_available_and_do_not_panic() { feedback: codex_feedback::CodexFeedback::new(), skills: None, is_first_run: true, + model_family, }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -394,6 +397,7 @@ fn make_chatwidget_manual() -> ( bottom_pane: bottom, active_cell: None, config: cfg.clone(), + model_family: ModelsManager::construct_model_family_offline(&cfg.model, &cfg), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(auth_manager)), session_header: SessionHeader::new(cfg.model), diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index e70a31ffc0e..945ed1f4916 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -27,7 +27,6 @@ use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; use codex_core::config::types::ReasoningSummaryFormat; -use codex_core::openai_models::model_family::ModelFamily; use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; @@ -1421,9 +1420,9 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor pub(crate) fn new_reasoning_summary_block( full_reasoning_buffer: String, - model_family: &ModelFamily, + reasoning_summary_format: ReasoningSummaryFormat, ) -> Box { - if model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental { + if reasoning_summary_format == ReasoningSummaryFormat::Experimental { // Experimental format is following: // ** header ** // @@ -1513,8 +1512,6 @@ mod tests { use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCall; use crate::exec_cell::ExecCell; - use codex_core::AuthManager; - use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -1527,7 +1524,6 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashMap; - use std::sync::Arc; use codex_core::protocol::ExecCommandSource; use mcp_types::CallToolResult; @@ -2326,13 +2322,12 @@ mod tests { #[test] fn reasoning_summary_block() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), - &model_family, + reasoning_format, ); let rendered_display = render_lines(&cell.display_lines(80)); @@ -2345,12 +2340,13 @@ mod tests { #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); - let cell = - new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &model_family); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; + let cell = new_reasoning_summary_block( + "Detailed reasoning goes here.".to_string(), + reasoning_format, + ); let rendered = render_transcript(cell.as_ref()); assert_eq!(rendered, vec!["• Detailed reasoning goes here."]); @@ -2362,11 +2358,7 @@ mod tests { config.model = "gpt-3.5-turbo".to_string(); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary_format = Some(ReasoningSummaryFormat::Experimental); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); assert_eq!( model_family.reasoning_summary_format, ReasoningSummaryFormat::Experimental @@ -2374,7 +2366,7 @@ mod tests { let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), - &model_family, + model_family.reasoning_summary_format, ); let rendered_display = render_lines(&cell.display_lines(80)); @@ -2384,13 +2376,12 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_header_is_missing() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; let cell = new_reasoning_summary_block( "**High level reasoning without closing".to_string(), - &model_family, + reasoning_format, ); let rendered = render_transcript(cell.as_ref()); @@ -2400,13 +2391,12 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_summary_is_missing() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; let cell = new_reasoning_summary_block( "**High level reasoning without closing**".to_string(), - &model_family, + reasoning_format.clone(), ); let rendered = render_transcript(cell.as_ref()); @@ -2414,7 +2404,7 @@ mod tests { let cell = new_reasoning_summary_block( "**High level reasoning without closing**\n\n ".to_string(), - &model_family, + reasoning_format, ); let rendered = render_transcript(cell.as_ref()); @@ -2424,13 +2414,12 @@ mod tests { #[test] fn reasoning_summary_block_splits_header_and_summary_when_present() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; let cell = new_reasoning_summary_block( "**High level plan**\n\nWe should fix the bug next.".to_string(), - &model_family, + reasoning_format, ); let rendered_display = render_lines(&cell.display_lines(80)); From a8cbbdbc6ec485e933cec3da88820db65d1c568c Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 5 Dec 2025 11:03:25 -0800 Subject: [PATCH 03/94] feat(core) Add login to shell_command tool (#6846) ## Summary Adds the `login` parameter to the `shell_command` tool - optional, defaults to true. ## Testing - [x] Tested locally --- AGENTS.md | 1 + codex-rs/core/src/shell.rs | 42 +++++ codex-rs/core/src/tools/handlers/shell.rs | 48 +++++- codex-rs/core/src/tools/spec.rs | 9 ++ codex-rs/core/tests/common/lib.rs | 12 ++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/shell_command.rs | 174 +++++++++++++++++++++ codex-rs/protocol/src/models.rs | 3 + 8 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 codex-rs/core/tests/suite/shell_command.rs diff --git a/AGENTS.md b/AGENTS.md index aaebd0dfd31..f9f04c5b152 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ If you don’t have the tool: ### Test assertions - Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already. +- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields. ### Integration tests (core) diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index ac115facb69..2338f41cd4f 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -408,6 +408,48 @@ mod tests { } } + #[test] + fn derive_exec_args() { + let test_bash_shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + }; + assert_eq!( + test_bash_shell.derive_exec_args("echo hello", false), + vec!["/bin/bash", "-c", "echo hello"] + ); + assert_eq!( + test_bash_shell.derive_exec_args("echo hello", true), + vec!["/bin/bash", "-lc", "echo hello"] + ); + + let test_zsh_shell = Shell { + shell_type: ShellType::Zsh, + shell_path: PathBuf::from("/bin/zsh"), + }; + assert_eq!( + test_zsh_shell.derive_exec_args("echo hello", false), + vec!["/bin/zsh", "-c", "echo hello"] + ); + assert_eq!( + test_zsh_shell.derive_exec_args("echo hello", true), + vec!["/bin/zsh", "-lc", "echo hello"] + ); + + let test_powershell_shell = Shell { + shell_type: ShellType::PowerShell, + shell_path: PathBuf::from("pwsh.exe"), + }; + assert_eq!( + test_powershell_shell.derive_exec_args("echo hello", false), + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"] + ); + assert_eq!( + test_powershell_shell.derive_exec_args("echo hello", true), + vec!["pwsh.exe", "-Command", "echo hello"] + ); + } + #[tokio::test] async fn test_current_shell_detects_zsh() { let shell = Command::new("sh") diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index cd05d126bf8..c3ef590e132 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -49,8 +49,7 @@ impl ShellCommandHandler { turn_context: &TurnContext, ) -> ExecParams { let shell = session.user_shell(); - let use_login_shell = true; - let command = shell.derive_exec_args(¶ms.command, use_login_shell); + let command = shell.derive_exec_args(¶ms.command, params.login.unwrap_or(true)); ExecParams { command, @@ -276,9 +275,15 @@ impl ShellHandler { mod tests { use std::path::PathBuf; + use codex_protocol::models::ShellCommandToolCallParams; + use pretty_assertions::assert_eq; + + use crate::codex::make_session_and_context; + use crate::exec_env::create_env; use crate::is_safe_command::is_known_safe_command; use crate::shell::Shell; use crate::shell::ShellType; + use crate::tools::handlers::ShellCommandHandler; /// The logic for is_known_safe_command() has heuristics for known shells, /// so we must ensure the commands generated by [ShellCommandHandler] can be @@ -312,4 +317,43 @@ mod tests { &shell.derive_exec_args(command, /* use_login_shell */ false) )); } + + #[test] + fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_context() { + let (session, turn_context) = make_session_and_context(); + + let command = "echo hello".to_string(); + let workdir = Some("subdir".to_string()); + let login = None; + let timeout_ms = Some(1234); + let with_escalated_permissions = Some(true); + let justification = Some("because tests".to_string()); + + let expected_command = session.user_shell().derive_exec_args(&command, true); + let expected_cwd = turn_context.resolve_path(workdir.clone()); + let expected_env = create_env(&turn_context.shell_environment_policy); + + let params = ShellCommandToolCallParams { + command, + workdir, + login, + timeout_ms, + with_escalated_permissions, + justification: justification.clone(), + }; + + let exec_params = ShellCommandHandler::to_exec_params(params, &session, &turn_context); + + // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. + assert_eq!(exec_params.command, expected_command); + assert_eq!(exec_params.cwd, expected_cwd); + assert_eq!(exec_params.env, expected_env); + assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); + assert_eq!( + exec_params.with_escalated_permissions, + with_escalated_permissions + ); + assert_eq!(exec_params.justification, justification); + assert_eq!(exec_params.arg0, None); + } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a36f54a6be0..e72becd8821 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -331,6 +331,15 @@ fn create_shell_command_tool() -> ToolSpec { description: Some("The working directory to execute the command in".to_string()), }, ); + properties.insert( + "login".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to run the shell with login shell semantics. Defaults to true." + .to_string(), + ), + }, + ); properties.insert( "timeout_ms".to_string(), JsonSchema::Number { diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index e7b1e71efa4..2c8c28d7127 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -369,3 +369,15 @@ macro_rules! skip_if_no_network { } }}; } + +#[macro_export] +macro_rules! skip_if_windows { + ($return_value:expr $(,)?) => {{ + if cfg!(target_os = "windows") { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return $return_value; + } + }}; +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 86f417801ae..e2d78004a5d 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -46,6 +46,7 @@ mod review; mod rmcp_client; mod rollout_list_find; mod seatbelt; +mod shell_command; mod shell_serialization; mod stream_error_allows_next_turn; mod stream_no_completed; diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs new file mode 100644 index 00000000000..10e972b3de3 --- /dev/null +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -0,0 +1,174 @@ +use anyhow::Result; +use core_test_support::assert_regex_match; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_windows; +use core_test_support::test_codex::TestCodexBuilder; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +use serde_json::json; + +fn shell_responses(call_id: &str, command: &str, login: Option) -> Vec { + let args = json!({ + "command": command, + "timeout_ms": 2_000, + "login": login, + }); + + #[allow(clippy::expect_used)] + let arguments = serde_json::to_string(&args).expect("serialize shell command arguments"); + + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell_command", &arguments), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ] +} + +async fn shell_command_harness_with( + configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder, +) -> Result { + let builder = configure(test_codex()).with_config(|config| { + config.include_apply_patch_tool = true; + }); + TestCodexHarness::with_builder(builder).await +} + +async fn mount_shell_responses( + harness: &TestCodexHarness, + call_id: &str, + command: &str, + login: Option, +) { + mount_sse_sequence(harness.server(), shell_responses(call_id, command, login)).await; +} + +fn assert_shell_command_output(output: &str, expected: &str) -> Result<()> { + let normalized_output = output + .replace("\r\n", "\n") + .replace('\r', "\n") + .trim_end_matches('\n') + .to_string(); + + let expected_pattern = format!( + r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n{expected}\n?$" + ); + + assert_regex_match(&expected_pattern, &normalized_output); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_command_works() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call"; + mount_shell_responses(&harness, call_id, "echo 'hello, world'", None).await; + harness.submit("run the echo command").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn output_with_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-login-true"; + mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(true)).await; + harness.submit("run the echo command with login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn output_without_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-login-false"; + mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(false)).await; + harness.submit("run the echo command without login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_line_output_with_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-first-extra-login"; + mount_shell_responses( + &harness, + call_id, + "echo 'first line\nsecond line'", + Some(true), + ) + .await; + harness.submit("run the command with login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "first line\nsecond line")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipe_output_with_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_windows!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-second-extra-no-login"; + mount_shell_responses(&harness, call_id, "echo 'hello, world' | cat", None).await; + harness.submit("run the command without login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipe_output_without_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_windows!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-third-extra-login-false"; + mount_shell_responses(&harness, call_id, "echo 'hello, world' | cat", Some(false)).await; + harness.submit("run the command without login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index daf98152d51..f93c157b7ca 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -348,6 +348,9 @@ pub struct ShellCommandToolCallParams { pub command: String, pub workdir: Option, + /// Whether to run the shell with login shell semantics + #[serde(skip_serializing_if = "Option::is_none")] + pub login: Option, /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, From f48d88067efae4a1cf5df6d7ec5210c728afa4e1 Mon Sep 17 00:00:00 2001 From: Pavel Krymets Date: Fri, 5 Dec 2025 12:09:43 -0800 Subject: [PATCH 04/94] Fix unified_exec on windows (#7620) Fix unified_exec on windows Requires removal of PSUEDOCONSOLE_INHERIT_CURSOR flag so child processed don't attempt to wait for cursor position response (and timeout). https://github.com/wezterm/wezterm/compare/main...pakrym:wezterm:PSUEDOCONSOLE_INHERIT_CURSOR?expand=1 --------- Co-authored-by: pakrym-oai --- codex-rs/Cargo.lock | 8 +- codex-rs/Cargo.toml | 3 +- codex-rs/core/tests/common/lib.rs | 4 +- codex-rs/core/tests/suite/unified_exec.rs | 92 ++++++++++++++++++++++- codex-rs/utils/pty/Cargo.toml | 2 +- codex-rs/utils/pty/src/lib.rs | 30 ++++++++ 6 files changed, 128 insertions(+), 11 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 48f87efc24c..d809adfad19 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2557,8 +2557,7 @@ dependencies = [ [[package]] name = "filedescriptor" version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d" dependencies = [ "libc", "thiserror 1.0.69", @@ -4632,8 +4631,7 @@ dependencies = [ [[package]] name = "portable-pty" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d" dependencies = [ "anyhow", "bitflags 1.3.2", @@ -4642,7 +4640,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix 0.28.0", + "nix 0.29.0", "serial2", "shared_library", "shell-words", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2339cd4e677..d3d3c36c3ef 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -178,8 +178,8 @@ seccompiler = "0.5.0" sentry = "0.34.0" serde = "1" serde_json = "1" -serde_yaml = "0.9" serde_with = "3.16" +serde_yaml = "0.9" serial_test = "3.2.0" sha1 = "0.10.6" sha2 = "0.10" @@ -288,6 +288,7 @@ opt-level = 0 # Uncomment to debug local changes. # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } +portable-pty = { git = "https://github.com/pakrym/wezterm", branch = "PSUEDOCONSOLE_INHERIT_CURSOR" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } # Uncomment to debug local changes. diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 2c8c28d7127..d643fb77fcf 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -374,9 +374,7 @@ macro_rules! skip_if_no_network { macro_rules! skip_if_windows { ($return_value:expr $(,)?) => {{ if cfg!(target_os = "windows") { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); + println!("Skipping test because it cannot execute on Windows."); return $return_value; } }}; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 5e8f5a8cdfb..33e469fc1ad 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1,4 +1,3 @@ -#![cfg(not(target_os = "windows"))] use std::collections::HashMap; use std::ffi::OsStr; use std::fs; @@ -24,6 +23,7 @@ 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::skip_if_sandbox; +use core_test_support::skip_if_windows; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; @@ -155,6 +155,7 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let builder = test_codex().with_config(|config| { config.include_apply_patch_tool = true; @@ -279,6 +280,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -350,6 +352,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { async fn unified_exec_resolves_relative_workdir() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -427,6 +430,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { async fn unified_exec_respects_workdir_override() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -505,6 +509,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { async fn unified_exec_emits_exec_command_end_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -591,6 +596,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -662,6 +668,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -761,6 +768,7 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -857,6 +865,7 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -978,6 +987,7 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1085,6 +1095,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { async fn unified_exec_respects_early_exit_notifications() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1177,6 +1188,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1338,6 +1350,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1442,6 +1455,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() async fn unified_exec_reuses_session_via_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1553,6 +1567,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { async fn unified_exec_streams_after_lagged_output() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1684,6 +1699,7 @@ PY async fn unified_exec_timeout_and_followup_poll() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1790,6 +1806,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { async fn unified_exec_formats_large_output_summary() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1875,6 +1892,7 @@ PY async fn unified_exec_runs_under_sandbox() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -2067,11 +2085,83 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_runs_on_all_platforms() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "uexec"; + let args = serde_json::json!({ + "cmd": "echo 'hello crossplat'", + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "summarize large output".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("recorded requests"); + assert!(!requests.is_empty(), "expected at least one POST request"); + + let bodies = requests + .iter() + .map(|req| req.body_json::().expect("request json")) + .collect::>(); + + let outputs = collect_tool_outputs(&bodies)?; + let output = outputs.get(call_id).expect("missing output"); + + // TODO: Weaker match because windows produces control characters + assert_regex_match(".*hello crossplat.*", &output.output); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore] async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml index d640c71aa78..2b3de5aa155 100644 --- a/codex-rs/utils/pty/Cargo.toml +++ b/codex-rs/utils/pty/Cargo.toml @@ -10,4 +10,4 @@ workspace = true [dependencies] anyhow = { workspace = true } portable-pty = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 14cc430760d..23d69b6f6a3 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -1,3 +1,4 @@ +use core::fmt; use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; @@ -9,13 +10,20 @@ use std::time::Duration; use anyhow::Result; use portable_pty::native_pty_system; use portable_pty::CommandBuilder; +use portable_pty::MasterPty; use portable_pty::PtySize; +use portable_pty::SlavePty; use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::Mutex as TokioMutex; use tokio::task::JoinHandle; +pub struct PtyPairWrapper { + pub _slave: Option>, + pub _master: Box, +} + #[derive(Debug)] pub struct ExecCommandSession { writer_tx: mpsc::Sender>, @@ -26,6 +34,15 @@ pub struct ExecCommandSession { wait_handle: StdMutex>>, exit_status: Arc, exit_code: Arc>>, + // PtyPair must be preserved because the process will receive Control+C if the + // slave is closed + _pair: StdMutex, +} + +impl fmt::Debug for PtyPairWrapper { + fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { + Ok(()) + } } impl ExecCommandSession { @@ -39,6 +56,7 @@ impl ExecCommandSession { wait_handle: JoinHandle<()>, exit_status: Arc, exit_code: Arc>>, + pair: PtyPairWrapper, ) -> (Self, broadcast::Receiver>) { let initial_output_rx = output_tx.subscribe(); ( @@ -51,6 +69,7 @@ impl ExecCommandSession { wait_handle: StdMutex::new(Some(wait_handle)), exit_status, exit_code, + _pair: StdMutex::new(pair), }, initial_output_rx, ) @@ -192,6 +211,16 @@ pub async fn spawn_pty_process( let _ = exit_tx.send(code); }); + let pair = PtyPairWrapper { + _slave: if cfg!(windows) { + // Keep the slave handle alive on Windows to prevent the process from receiving Control+C + Some(pair.slave) + } else { + None + }, + _master: pair.master, + }; + let (session, output_rx) = ExecCommandSession::new( writer_tx, output_tx, @@ -201,6 +230,7 @@ pub async fn spawn_pty_process( wait_handle, exit_status, exit_code, + pair, ); Ok(SpawnedPty { From 2e4a40252157751765dff176b35c692df8a9fb4e Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:39:23 -0800 Subject: [PATCH 05/94] cloud: status, diff, apply (#7614) Adds cli commands for getting the status of cloud tasks, and for getting/applying the diffs from same. --- codex-rs/Cargo.lock | 23 +- codex-rs/cloud-tasks-client/src/api.rs | 1 + codex-rs/cloud-tasks-client/src/http.rs | 112 +++++++ codex-rs/cloud-tasks-client/src/mock.rs | 9 + codex-rs/cloud-tasks/Cargo.toml | 3 + codex-rs/cloud-tasks/src/app.rs | 12 + codex-rs/cloud-tasks/src/cli.rs | 35 +++ codex-rs/cloud-tasks/src/lib.rs | 374 ++++++++++++++++++++++++ codex-rs/cloud-tasks/src/ui.rs | 26 +- codex-rs/cloud-tasks/src/util.rs | 26 ++ 10 files changed, 594 insertions(+), 27 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d809adfad19..b77cf01b02e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ "pretty_assertions", "regex-lite", "serde_json", - "supports-color", + "supports-color 3.0.2", "tempfile", "tokio", "toml", @@ -1088,10 +1088,13 @@ dependencies = [ "codex-login", "codex-tui", "crossterm", + "owo-colors", + "pretty_assertions", "ratatui", "reqwest", "serde", "serde_json", + "supports-color 3.0.2", "tokio", "tokio-stream", "tracing", @@ -1237,7 +1240,7 @@ dependencies = [ "serde", "serde_json", "shlex", - "supports-color", + "supports-color 3.0.2", "tempfile", "tokio", "tracing", @@ -1611,7 +1614,7 @@ dependencies = [ "shlex", "strum 0.27.2", "strum_macros 0.27.2", - "supports-color", + "supports-color 3.0.2", "tempfile", "textwrap 0.16.2", "tokio", @@ -4433,6 +4436,10 @@ name = "owo-colors" version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] [[package]] name = "parking" @@ -6168,6 +6175,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + [[package]] name = "supports-color" version = "3.0.2" diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs index 4bd12939e84..cd8228bc280 100644 --- a/codex-rs/cloud-tasks-client/src/api.rs +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -127,6 +127,7 @@ impl Default for TaskText { #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn get_task_summary(&self, id: TaskId) -> Result; async fn get_task_diff(&self, id: TaskId) -> Result>; /// Return assistant output messages (no diff) when available. async fn get_task_messages(&self, id: TaskId) -> Result>; diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 57d39b7bda8..f55d0fe7971 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -63,6 +63,10 @@ impl CloudBackend for HttpClient { self.tasks_api().list(env).await } + async fn get_task_summary(&self, id: TaskId) -> Result { + self.tasks_api().summary(id).await + } + async fn get_task_diff(&self, id: TaskId) -> Result> { self.tasks_api().diff(id).await } @@ -149,6 +153,75 @@ mod api { Ok(tasks) } + pub(crate) async fn summary(&self, id: TaskId) -> Result { + let id_str = id.0.clone(); + let (details, body, ct) = self + .details_with_body(&id.0) + .await + .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; + let parsed: Value = serde_json::from_str(&body).map_err(|e| { + CloudTaskError::Http(format!( + "Decode error for {}: {e}; content-type={ct}; body={body}", + id.0 + )) + })?; + let task_obj = parsed + .get("task") + .and_then(Value::as_object) + .ok_or_else(|| { + CloudTaskError::Http(format!("Task metadata missing from details for {id_str}")) + })?; + let status_display = parsed + .get("task_status_display") + .or_else(|| task_obj.get("task_status_display")) + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }); + let status = map_status(status_display.as_ref()); + let mut summary = diff_summary_from_status_display(status_display.as_ref()); + if summary.files_changed == 0 + && summary.lines_added == 0 + && summary.lines_removed == 0 + && let Some(diff) = details.unified_diff() + { + summary = diff_summary_from_diff(&diff); + } + let updated_at_raw = task_obj + .get("updated_at") + .and_then(Value::as_f64) + .or_else(|| task_obj.get("created_at").and_then(Value::as_f64)) + .or_else(|| latest_turn_timestamp(status_display.as_ref())); + let environment_id = task_obj + .get("environment_id") + .and_then(Value::as_str) + .map(str::to_string); + let environment_label = env_label_from_status_display(status_display.as_ref()); + let attempt_total = attempt_total_from_status_display(status_display.as_ref()); + let title = task_obj + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let is_review = task_obj + .get("is_review") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(TaskSummary { + id, + title, + status, + updated_at: parse_updated_at(updated_at_raw.as_ref()), + environment_id, + environment_label, + summary, + is_review, + attempt_total, + }) + } + pub(crate) async fn diff(&self, id: TaskId) -> Result> { let (details, body, ct) = self .details_with_body(&id.0) @@ -679,6 +752,34 @@ mod api { .map(str::to_string) } + fn diff_summary_from_diff(diff: &str) -> DiffSummary { + let mut files_changed = 0usize; + let mut lines_added = 0usize; + let mut lines_removed = 0usize; + for line in diff.lines() { + if line.starts_with("diff --git ") { + files_changed += 1; + continue; + } + if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") { + continue; + } + match line.as_bytes().first() { + Some(b'+') => lines_added += 1, + Some(b'-') => lines_removed += 1, + _ => {} + } + } + if files_changed == 0 && !diff.trim().is_empty() { + files_changed = 1; + } + DiffSummary { + files_changed, + lines_added, + lines_removed, + } + } + fn diff_summary_from_status_display(v: Option<&HashMap>) -> DiffSummary { let mut out = DiffSummary::default(); let Some(map) = v else { return out }; @@ -700,6 +801,17 @@ mod api { out } + fn latest_turn_timestamp(v: Option<&HashMap>) -> Option { + let map = v?; + let latest = map + .get("latest_turn_status_display") + .and_then(Value::as_object)?; + latest + .get("updated_at") + .or_else(|| latest.get("created_at")) + .and_then(Value::as_f64) + } + fn attempt_total_from_status_display(v: Option<&HashMap>) -> Option { let map = v?; let latest = map diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs index 97bc5520a83..2d03cea029f 100644 --- a/codex-rs/cloud-tasks-client/src/mock.rs +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -1,6 +1,7 @@ use crate::ApplyOutcome; use crate::AttemptStatus; use crate::CloudBackend; +use crate::CloudTaskError; use crate::DiffSummary; use crate::Result; use crate::TaskId; @@ -60,6 +61,14 @@ impl CloudBackend for MockClient { Ok(out) } + async fn get_task_summary(&self, id: TaskId) -> Result { + let tasks = self.list_tasks(None).await?; + tasks + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found (mock)", id.0))) + } + async fn get_task_diff(&self, id: TaskId) -> Result> { Ok(Some(mock_diff_for(&id))) } diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index c9edf5b4ad5..188538bec68 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -34,6 +34,9 @@ tokio-stream = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } unicode-width = { workspace = true } +owo-colors = { workspace = true, features = ["supports-colors"] } +supports-color = { workspace = true } [dev-dependencies] async-trait = { workspace = true } +pretty_assertions = { workspace = true } diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index 612c5f6be4b..ce12128a3ea 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -350,6 +350,7 @@ pub enum AppEvent { mod tests { use super::*; use chrono::Utc; + use codex_cloud_tasks_client::CloudTaskError; struct FakeBackend { // maps env key to titles @@ -385,6 +386,17 @@ mod tests { Ok(out) } + async fn get_task_summary( + &self, + id: TaskId, + ) -> codex_cloud_tasks_client::Result { + self.list_tasks(None) + .await? + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0))) + } + async fn get_task_diff( &self, _id: TaskId, diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs index 9c118038eb1..a7612153b4e 100644 --- a/codex-rs/cloud-tasks/src/cli.rs +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -16,6 +16,12 @@ pub struct Cli { pub enum Command { /// Submit a new Codex Cloud task without launching the TUI. Exec(ExecCommand), + /// Show the status of a Codex Cloud task. + Status(StatusCommand), + /// Apply the diff for a Codex Cloud task locally. + Apply(ApplyCommand), + /// Show the unified diff for a Codex Cloud task. + Diff(DiffCommand), } #[derive(Debug, Args)] @@ -51,3 +57,32 @@ fn parse_attempts(input: &str) -> Result { Err("attempts must be between 1 and 4".to_string()) } } + +#[derive(Debug, Args)] +pub struct StatusCommand { + /// Codex Cloud task identifier to inspect. + #[arg(value_name = "TASK_ID")] + pub task_id: String, +} + +#[derive(Debug, Args)] +pub struct ApplyCommand { + /// Codex Cloud task identifier to apply. + #[arg(value_name = "TASK_ID")] + pub task_id: String, + + /// Attempt number to apply (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, +} + +#[derive(Debug, Args)] +pub struct DiffCommand { + /// Codex Cloud task identifier to display. + #[arg(value_name = "TASK_ID")] + pub task_id: String, + + /// Attempt number to display (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, +} diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 1a3798f7580..f73e07f3afb 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -8,17 +8,24 @@ pub mod util; pub use cli::Cli; use anyhow::anyhow; +use chrono::Utc; +use codex_cloud_tasks_client::TaskStatus; use codex_login::AuthManager; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use std::cmp::Ordering; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; +use supports_color::Stream as SupportStream; use tokio::sync::mpsc::UnboundedSender; use tracing::info; use tracing_subscriber::EnvFilter; use util::append_error_log; +use util::format_relative_time; use util::set_user_agent_suffix; struct ApplyJob { @@ -193,6 +200,273 @@ fn resolve_query_input(query_arg: Option) -> anyhow::Result { } } +fn parse_task_id(raw: &str) -> anyhow::Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + anyhow::bail!("task id must not be empty"); + } + let without_fragment = trimmed.split('#').next().unwrap_or(trimmed); + let without_query = without_fragment + .split('?') + .next() + .unwrap_or(without_fragment); + let id = without_query + .rsplit('/') + .next() + .unwrap_or(without_query) + .trim(); + if id.is_empty() { + anyhow::bail!("task id must not be empty"); + } + Ok(codex_cloud_tasks_client::TaskId(id.to_string())) +} + +#[derive(Clone, Debug)] +struct AttemptDiffData { + placement: Option, + created_at: Option>, + diff: String, +} + +fn cmp_attempt(lhs: &AttemptDiffData, rhs: &AttemptDiffData) -> Ordering { + match (lhs.placement, rhs.placement) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => match (lhs.created_at, rhs.created_at) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + }, + } +} + +async fn collect_attempt_diffs( + backend: &dyn codex_cloud_tasks_client::CloudBackend, + task_id: &codex_cloud_tasks_client::TaskId, +) -> anyhow::Result> { + let text = + codex_cloud_tasks_client::CloudBackend::get_task_text(backend, task_id.clone()).await?; + let mut attempts = Vec::new(); + if let Some(diff) = + codex_cloud_tasks_client::CloudBackend::get_task_diff(backend, task_id.clone()).await? + { + attempts.push(AttemptDiffData { + placement: text.attempt_placement, + created_at: None, + diff, + }); + } + if let Some(turn_id) = text.turn_id { + let siblings = codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( + backend, + task_id.clone(), + turn_id, + ) + .await?; + for sibling in siblings { + if let Some(diff) = sibling.diff { + attempts.push(AttemptDiffData { + placement: sibling.attempt_placement, + created_at: sibling.created_at, + diff, + }); + } + } + } + attempts.sort_by(cmp_attempt); + if attempts.is_empty() { + anyhow::bail!( + "No diff available for task {}; it may still be running.", + task_id.0 + ); + } + Ok(attempts) +} + +fn select_attempt( + attempts: &[AttemptDiffData], + attempt: Option, +) -> anyhow::Result<&AttemptDiffData> { + if attempts.is_empty() { + anyhow::bail!("No attempts available"); + } + let desired = attempt.unwrap_or(1); + let idx = desired + .checked_sub(1) + .ok_or_else(|| anyhow!("attempt must be at least 1"))?; + if idx >= attempts.len() { + anyhow::bail!( + "Attempt {desired} not available; only {} attempt(s) found", + attempts.len() + ); + } + Ok(&attempts[idx]) +} + +fn task_status_label(status: &TaskStatus) -> &'static str { + match status { + TaskStatus::Pending => "PENDING", + TaskStatus::Ready => "READY", + TaskStatus::Applied => "APPLIED", + TaskStatus::Error => "ERROR", + } +} + +fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) -> String { + if summary.files_changed == 0 && summary.lines_added == 0 && summary.lines_removed == 0 { + let base = "no diff"; + return if colorize { + base.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + base.to_string() + }; + } + let adds = summary.lines_added; + let dels = summary.lines_removed; + let files = summary.files_changed; + if colorize { + let adds_raw = format!("+{adds}"); + let adds_str = adds_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(); + let dels_raw = format!("-{dels}"); + let dels_str = dels_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(); + let bullet = "•" + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + let file_label = "file" + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + let plural = if files == 1 { "" } else { "s" }; + format!("{adds_str}/{dels_str} {bullet} {files} {file_label}{plural}") + } else { + format!( + "+{adds}/-{dels} • {files} file{}", + if files == 1 { "" } else { "s" } + ) + } +} + +fn format_task_status_lines( + task: &codex_cloud_tasks_client::TaskSummary, + now: chrono::DateTime, + colorize: bool, +) -> Vec { + let mut lines = Vec::new(); + let status = task_status_label(&task.status); + let status = if colorize { + match task.status { + TaskStatus::Ready => status + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(), + TaskStatus::Pending => status + .if_supports_color(Stream::Stdout, |t| t.magenta()) + .to_string(), + TaskStatus::Applied => status + .if_supports_color(Stream::Stdout, |t| t.blue()) + .to_string(), + TaskStatus::Error => status + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(), + } + } else { + status.to_string() + }; + lines.push(format!("[{status}] {}", task.title)); + let mut meta_parts = Vec::new(); + if let Some(label) = task.environment_label.as_deref().filter(|s| !s.is_empty()) { + if colorize { + meta_parts.push( + label + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(label.to_string()); + } + } else if let Some(id) = task.environment_id.as_deref() { + if colorize { + meta_parts.push( + id.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(id.to_string()); + } + } + let when = format_relative_time(now, task.updated_at); + meta_parts.push(if colorize { + when.as_str() + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + when + }); + let sep = if colorize { + " • " + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + " • ".to_string() + }; + lines.push(meta_parts.join(&sep)); + lines.push(summary_line(&task.summary, colorize)); + lines +} + +async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_status").await?; + let task_id = parse_task_id(&args.task_id)?; + let summary = + codex_cloud_tasks_client::CloudBackend::get_task_summary(&*ctx.backend, task_id).await?; + let now = Utc::now(); + let colorize = supports_color::on(SupportStream::Stdout).is_some(); + for line in format_task_status_lines(&summary, now, colorize) { + println!("{line}"); + } + if !matches!(summary.status, TaskStatus::Ready) { + std::process::exit(1); + } + Ok(()) +} + +async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_diff").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + print!("{}", selected.diff); + Ok(()) +} + +async fn run_apply_command(args: crate::cli::ApplyCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_apply").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + let outcome = codex_cloud_tasks_client::CloudBackend::apply_task( + &*ctx.backend, + task_id, + Some(selected.diff.clone()), + ) + .await?; + println!("{}", outcome.message); + if !matches!( + outcome.status, + codex_cloud_tasks_client::ApplyStatus::Success + ) { + std::process::exit(1); + } + Ok(()) +} + fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { match status { codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, @@ -322,6 +596,9 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an if let Some(command) = cli.command { return match command { crate::cli::Command::Exec(args) => run_exec_command(args).await, + crate::cli::Command::Status(args) => run_status_command(args).await, + crate::cli::Command::Apply(args) => run_apply_command(args).await, + crate::cli::Command::Diff(args) => run_diff_command(args).await, }; } let Cli { .. } = cli; @@ -1713,14 +1990,111 @@ fn pretty_lines_from_error(raw: &str) -> Vec { #[cfg(test)] mod tests { + use super::*; + use codex_cloud_tasks_client::DiffSummary; + use codex_cloud_tasks_client::MockClient; + use codex_cloud_tasks_client::TaskId; + use codex_cloud_tasks_client::TaskStatus; + use codex_cloud_tasks_client::TaskSummary; use codex_tui::ComposerAction; use codex_tui::ComposerInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + #[test] + fn format_task_status_lines_with_diff_and_label() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_1".to_string()), + title: "Example task".to_string(), + status: TaskStatus::Ready, + updated_at: now, + environment_id: Some("env-1".to_string()), + environment_label: Some("Env".to_string()), + summary: DiffSummary { + files_changed: 3, + lines_added: 5, + lines_removed: 2, + }, + is_review: false, + attempt_total: None, + }; + let lines = format_task_status_lines(&task, now, false); + assert_eq!( + lines, + vec![ + "[READY] Example task".to_string(), + "Env • 0s ago".to_string(), + "+5/-2 • 3 files".to_string(), + ] + ); + } + + #[test] + fn format_task_status_lines_without_diff_falls_back() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_2".to_string()), + title: "No diff task".to_string(), + status: TaskStatus::Pending, + updated_at: now, + environment_id: Some("env-2".to_string()), + environment_label: None, + summary: DiffSummary::default(), + is_review: false, + attempt_total: Some(1), + }; + let lines = format_task_status_lines(&task, now, false); + assert_eq!( + lines, + vec![ + "[PENDING] No diff task".to_string(), + "env-2 • 0s ago".to_string(), + "no diff".to_string(), + ] + ); + } + + #[tokio::test] + async fn collect_attempt_diffs_includes_sibling_attempts() { + let backend = MockClient; + let task_id = parse_task_id("https://chatgpt.com/codex/tasks/T-1000").expect("id"); + let attempts = collect_attempt_diffs(&backend, &task_id) + .await + .expect("attempts"); + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0].placement, Some(0)); + assert_eq!(attempts[1].placement, Some(1)); + assert!(!attempts[0].diff.is_empty()); + assert!(!attempts[1].diff.is_empty()); + } + + #[test] + fn select_attempt_validates_bounds() { + let attempts = vec![AttemptDiffData { + placement: Some(0), + created_at: None, + diff: "diff --git a/file b/file\n".to_string(), + }]; + let first = select_attempt(&attempts, Some(1)).expect("attempt 1"); + assert_eq!(first.diff, "diff --git a/file b/file\n"); + assert!(select_attempt(&attempts, Some(2)).is_err()); + } + + #[test] + fn parse_task_id_from_url_and_raw() { + let raw = parse_task_id("task_i_abc123").expect("raw id"); + assert_eq!(raw.0, "task_i_abc123"); + let url = + parse_task_id("https://chatgpt.com/codex/tasks/task_i_123456?foo=bar").expect("url id"); + assert_eq!(url.0, "task_i_123456"); + assert!(parse_task_id(" ").is_err()); + } + #[test] #[ignore = "very slow"] fn composer_input_renders_typed_characters() { diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index e3a97aeb3f8..4c41ca576cf 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -20,8 +20,7 @@ use std::time::Instant; use crate::app::App; use crate::app::AttemptView; -use chrono::Local; -use chrono::Utc; +use crate::util::format_relative_time_now; use codex_cloud_tasks_client::AttemptStatus; use codex_cloud_tasks_client::TaskStatus; use codex_tui::render_markdown_text; @@ -804,7 +803,7 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) { meta.push(lbl.clone().dim()); } - let when = format_relative_time(t.updated_at).dim(); + let when = format_relative_time_now(t.updated_at).dim(); if !meta.is_empty() { meta.push(" ".into()); meta.push("•".dim()); @@ -841,27 +840,6 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li ListItem::new(vec![title, meta_line, sub, spacer]) } -fn format_relative_time(ts: chrono::DateTime) -> String { - let now = Utc::now(); - let mut secs = (now - ts).num_seconds(); - if secs < 0 { - secs = 0; - } - if secs < 60 { - return format!("{secs}s ago"); - } - let mins = secs / 60; - if mins < 60 { - return format!("{mins}m ago"); - } - let hours = mins / 60; - if hours < 24 { - return format!("{hours}h ago"); - } - let local = ts.with_timezone(&Local); - local.format("%b %e %H:%M").to_string() -} - fn draw_inline_spinner( frame: &mut Frame, area: Rect, diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 1c690b26c0b..79513dbcf2c 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -1,4 +1,6 @@ use base64::Engine as _; +use chrono::DateTime; +use chrono::Local; use chrono::Utc; use reqwest::header::HeaderMap; @@ -120,3 +122,27 @@ pub fn task_url(base_url: &str, task_id: &str) -> String { } format!("{normalized}/codex/tasks/{task_id}") } + +pub fn format_relative_time(reference: DateTime, ts: DateTime) -> String { + let mut secs = (reference - ts).num_seconds(); + if secs < 0 { + secs = 0; + } + if secs < 60 { + return format!("{secs}s ago"); + } + let mins = secs / 60; + if mins < 60 { + return format!("{mins}m ago"); + } + let hours = mins / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let local = ts.with_timezone(&Local); + local.format("%b %e %H:%M").to_string() +} + +pub fn format_relative_time_now(ts: DateTime) -> String { + format_relative_time(Utc::now(), ts) +} From 952d6c94650acec024ed832ced24d2797f98f787 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Fri, 5 Dec 2025 16:24:55 -0800 Subject: [PATCH 06/94] Move justfile to repository root (#7652) ## Summary - move the workspace justfile to the repository root for easier discovery - set the just working directory to codex-rs so existing recipes still run in the Rust workspace ## Testing - not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_69334db473108329b0cc253b7fd8218e) --- codex-rs/justfile => justfile | 1 + 1 file changed, 1 insertion(+) rename codex-rs/justfile => justfile (97%) diff --git a/codex-rs/justfile b/justfile similarity index 97% rename from codex-rs/justfile rename to justfile index b1b9d7337b8..79b691e0a02 100644 --- a/codex-rs/justfile +++ b/justfile @@ -1,3 +1,4 @@ +set working-directory := "codex-rs" set positional-arguments # Display help From 6c9c563faf5708f0afe2850b831bb276853e3b49 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 5 Dec 2025 16:43:27 -0800 Subject: [PATCH 07/94] fix(apply-patch): preserve CRLF line endings on Windows (#7515) ## Summary This PR is heavily based on #4017, which contains the core logic for the fix. To reduce the risk, we are first introducing it only on windows. We can then expand to wsl / other environments as needed, and then tackle net new files. ## Testing - [x] added unit tests in apply-patch - [x] add integration tests to apply_patch_cli.rs --------- Co-authored-by: Chase Naples --- codex-rs/apply-patch/src/lib.rs | 161 ++++++++++++++++++- codex-rs/core/tests/suite/apply_patch_cli.rs | 91 +++++++++++ 2 files changed, 244 insertions(+), 8 deletions(-) diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 867d19a2e8d..28dc14eb02f 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -699,13 +699,7 @@ fn derive_new_contents_from_chunks( } }; - 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(String::is_empty) { - original_lines.pop(); - } + let original_lines: Vec = build_lines_from_contents(&original_contents); let replacements = compute_replacements(&original_lines, path, chunks)?; let new_lines = apply_replacements(original_lines, &replacements); @@ -713,13 +707,67 @@ fn derive_new_contents_from_chunks( if !new_lines.last().is_some_and(String::is_empty) { new_lines.push(String::new()); } - let new_contents = new_lines.join("\n"); + let new_contents = build_contents_from_lines(&original_contents, &new_lines); Ok(AppliedPatch { original_contents, new_contents, }) } +// TODO(dylan-hurd-oai): I think we can migrate to just use `contents.lines()` +// across all platforms. +fn build_lines_from_contents(contents: &str) -> Vec { + if cfg!(windows) { + contents.lines().map(String::from).collect() + } else { + let mut lines: Vec = 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 lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + + lines + } +} + +fn build_contents_from_lines(original_contents: &str, lines: &[String]) -> String { + if cfg!(windows) { + // for now, only compute this if we're on Windows. + let uses_crlf = contents_uses_crlf(original_contents); + if uses_crlf { + lines.join("\r\n") + } else { + lines.join("\n") + } + } else { + lines.join("\n") + } +} + +/// Detects whether the source file uses Windows CRLF line endings consistently. +/// We only consider a file CRLF-formatted if every newline is part of a +/// CRLF sequence. This avoids rewriting an LF-formatted file that merely +/// contains embedded sequences of "\r\n". +/// +/// Returns `true` if the file uses CRLF line endings, `false` otherwise. +fn contents_uses_crlf(contents: &str) -> bool { + let bytes = contents.as_bytes(); + let mut n_newlines = 0usize; + let mut n_crlf = 0usize; + for i in 0..bytes.len() { + if bytes[i] == b'\n' { + n_newlines += 1; + if i > 0 && bytes[i - 1] == b'\r' { + n_crlf += 1; + } + } + } + + n_newlines > 0 && n_crlf == n_newlines +} + /// Compute a list of replacements needed to transform `original_lines` into the /// new lines, given the patch `chunks`. Each replacement is returned as /// `(start_index, old_len, new_lines)`. @@ -1359,6 +1407,72 @@ PATCH"#, assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n"); } + /// Ensure CRLF line endings are preserved for updated files on Windows‑style inputs. + #[cfg(windows)] + #[test] + fn test_preserve_crlf_line_endings_on_update() { + let dir = tempdir().unwrap(); + let path = dir.path().join("crlf.txt"); + + // Original file uses CRLF (\r\n) endings. + std::fs::write(&path, b"a\r\nb\r\nc\r\n").unwrap(); + + // Replace `b` -> `B` and append `d`. + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + a +-b ++B +@@ + c ++d +*** End of File"#, + path.display() + )); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + + let out = std::fs::read(&path).unwrap(); + // Expect all CRLF endings; count occurrences of CRLF and ensure there are 4 lines. + let content = String::from_utf8_lossy(&out); + assert!(content.contains("\r\n")); + // No bare LF occurrences immediately preceding a non-CR: the text should not contain "a\nb". + assert!(!content.contains("a\nb")); + // Validate exact content sequence with CRLF delimiters. + assert_eq!(content, "a\r\nB\r\nc\r\nd\r\n"); + } + + /// Ensure CRLF inputs with embedded carriage returns in the content are preserved. + #[cfg(windows)] + #[test] + fn test_preserve_crlf_embedded_carriage_returns_on_append() { + let dir = tempdir().unwrap(); + let path = dir.path().join("crlf_cr_content.txt"); + + // Original file: first line has a literal '\r' in the content before the CRLF terminator. + std::fs::write(&path, b"foo\r\r\nbar\r\n").unwrap(); + + // Append a new line without modifying existing ones. + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ ++BAZ +*** End of File"#, + path.display() + )); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + + let out = std::fs::read(&path).unwrap(); + // CRLF endings must be preserved and the extra CR in "foo\r\r" must not be collapsed. + assert_eq!(out.as_slice(), b"foo\r\r\nbar\r\nBAZ\r\n"); + } + #[test] fn test_pure_addition_chunk_followed_by_removal() { let dir = tempdir().unwrap(); @@ -1544,6 +1658,37 @@ PATCH"#, assert_eq!(expected, diff); } + /// For LF-only inputs with a trailing newline ensure that the helper used + /// on Windows-style builds drops the synthetic trailing empty element so + /// replacements behave like standard `diff` line numbering. + #[test] + fn test_derive_new_contents_lf_trailing_newline() { + let dir = tempdir().unwrap(); + let path = dir.path().join("lf_trailing_newline.txt"); + fs::write(&path, "foo\nbar\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo +-bar ++BAR +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let AppliedPatch { new_contents, .. } = + derive_new_contents_from_chunks(&path, chunks).unwrap(); + + assert_eq!(new_contents, "foo\nBAR\n"); + } + #[test] fn test_unified_diff_insert_at_eof() { // Insert a new line at end‑of‑file. diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 880e74d9592..70c8aa4fa4f 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1250,3 +1250,94 @@ async fn apply_patch_change_context_disambiguates_target( assert_eq!(contents, "fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n"); Ok(()) } + +/// Ensure that applying a patch can update a CRLF file with unicode characters. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(ApplyPatchModelOutput::Freeform)] +#[test_case(ApplyPatchModelOutput::Function)] +#[test_case(ApplyPatchModelOutput::Shell)] +#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] +#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] +async fn apply_patch_cli_updates_unicode_characters( + model_output: ApplyPatchModelOutput, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("unicode.txt"); + fs::write(&target, "first ⚠️\nsecond ❌\nthird 🔥\n")?; + + let patch = format!( + r#"*** Begin Patch +*** Update File: {} +@@ + first ⚠️ +-second ❌ ++SECOND ✅ +@@ + third 🔥 ++FOURTH +*** End of File +*** End Patch"#, + target.display() + ); + let call_id = "apply-unicode-update"; + mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await; + + harness + .submit("update unicode characters via apply_patch CLI") + .await?; + + let file_contents = fs::read(&target)?; + let content = String::from_utf8_lossy(&file_contents); + assert_eq!(content, "first ⚠️\nSECOND ✅\nthird 🔥\nFOURTH\n"); + Ok(()) +} + +/// Ensure that applying a patch via the CLI preserves CRLF line endings for +/// Windows-style inputs even when updating the file contents. +#[cfg(windows)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(ApplyPatchModelOutput::Freeform)] +#[test_case(ApplyPatchModelOutput::Function)] +#[test_case(ApplyPatchModelOutput::Shell)] +#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] +#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] +async fn apply_patch_cli_updates_crlf_file_preserves_line_endings( + model_output: ApplyPatchModelOutput, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("crlf.txt"); + fs::write(&target, b"first\r\nsecond\r\nthird\r\n")?; + + let patch = format!( + r#"*** Begin Patch +*** Update File: {} +@@ + first +-second ++SECOND +@@ + third ++FOURTH +*** End of File +*** End Patch"#, + target.display() + ); + let call_id = "apply-crlf-update"; + mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await; + + harness + .submit("update crlf file via apply_patch CLI") + .await?; + + let file_contents = fs::read(&target)?; + let content = String::from_utf8_lossy(&file_contents); + assert!(content.contains("\r\n")); + assert_eq!(content, "first\r\nSECOND\r\nthird\r\nFOURTH\r\n"); + Ok(()) +} From 93f61dbc5febb9a054d729e4b585834628c60bf4 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 5 Dec 2025 18:01:49 -0800 Subject: [PATCH 08/94] Also load skills from repo root. (#7645) Also load skills from /REPO_ROOT/codex/skills. --- codex-rs/core/src/skills/loader.rs | 56 ++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index a9ea156f021..c014af31477 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,4 +1,5 @@ use crate::config::Config; +use crate::git_info::resolve_root_git_project_for_trust; use crate::skills::model::SkillError; use crate::skills::model::SkillLoadOutcome; use crate::skills::model::SkillMetadata; @@ -20,6 +21,7 @@ struct SkillFrontmatter { const SKILLS_FILENAME: &str = "SKILL.md"; const SKILLS_DIR_NAME: &str = "skills"; +const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex"; const MAX_NAME_LEN: usize = 100; const MAX_DESCRIPTION_LEN: usize = 500; @@ -65,7 +67,17 @@ pub fn load_skills(config: &Config) -> SkillLoadOutcome { } fn skill_roots(config: &Config) -> Vec { - vec![config.codex_home.join(SKILLS_DIR_NAME)] + let mut roots = vec![config.codex_home.join(SKILLS_DIR_NAME)]; + + if let Some(repo_root) = resolve_root_git_project_for_trust(&config.cwd) { + roots.push( + repo_root + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + ); + } + + roots } fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { @@ -196,6 +208,9 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use pretty_assertions::assert_eq; + use std::path::Path; + use std::process::Command; use tempfile::TempDir; fn make_config(codex_home: &TempDir) -> Config { @@ -211,7 +226,11 @@ mod tests { } fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { - let skill_dir = codex_home.path().join(format!("skills/{dir}")); + write_skill_at(codex_home.path(), dir, name, description) + } + + fn write_skill_at(root: &Path, dir: &str, name: &str, description: &str) -> PathBuf { + let skill_dir = root.join(format!("skills/{dir}")); fs::create_dir_all(&skill_dir).unwrap(); let indented_description = description.replace('\n', "\n "); let content = format!( @@ -288,4 +307,37 @@ mod tests { "expected length error" ); } + + #[test] + fn loads_skills_from_repo_root() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + + let status = Command::new("git") + .arg("init") + .current_dir(repo_dir.path()) + .status() + .expect("git init"); + assert!(status.success(), "git init failed"); + + let skills_root = repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME); + write_skill_at(&skills_root, "repo", "repo-skill", "from repo"); + let mut cfg = make_config(&codex_home); + cfg.cwd = repo_dir.path().to_path_buf(); + let repo_root = normalize_path(&skills_root).unwrap_or_else(|_| skills_root.clone()); + + let outcome = load_skills(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + let skill = &outcome.skills[0]; + assert_eq!(skill.name, "repo-skill"); + assert!(skill.path.starts_with(&repo_root)); + } } From f521d29726f3b3b0228902754e116f678b8b3a5f Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 6 Dec 2025 05:46:44 +0100 Subject: [PATCH 09/94] fix: OTEL HTTP exporter panic and mTLS support (#7651) This fixes two issues with the OTEL HTTP exporter: 1. **Runtime panic with async reqwest client** The `opentelemetry_sdk` `BatchLogProcessor` spawns a dedicated OS thread that uses `futures_executor::block_on()` rather than tokio's runtime. When the async reqwest client's timeout mechanism calls `tokio::time::sleep()`, it panics with "there is no reactor running, must be called from the context of a Tokio 1.x runtime". The fix is to use `reqwest::blocking::Client` instead, which doesn't depend on tokio for timeouts. However, the blocking client creates its own internal tokio runtime during construction, which would panic if built from within an async context. We wrap the construction in `tokio::task::block_in_place()` to handle this. 2. **mTLS certificate handling** The HTTP client wasn't properly configured for mTLS, matching the fixes previously done for the model provider client: - Added `.tls_built_in_root_certs(false)` when using a custom CA certificate to ensure only our CA is trusted - Added `.https_only(true)` when using client identity - Added `rustls-tls` feature to ensure rustls is used (required for `Identity::from_pem()` to work correctly) --- codex-rs/otel/Cargo.toml | 4 ++-- codex-rs/otel/src/otel_provider.rs | 28 +++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index 059e8e38e63..5ed6c094985 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -29,7 +29,7 @@ opentelemetry-otlp = { workspace = true, features = [ "http-proto", "http-json", "logs", - "reqwest", + "reqwest-blocking-client", "reqwest-rustls", "tls", "tls-roots", @@ -40,7 +40,7 @@ opentelemetry_sdk = { workspace = true, features = [ "rt-tokio", ], optional = true } http = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs index 8be2431ea94..5495db0ad35 100644 --- a/codex-rs/otel/src/otel_provider.rs +++ b/codex-rs/otel/src/otel_provider.rs @@ -182,12 +182,27 @@ fn build_grpc_tls_config( Ok(config) } +/// Build a blocking HTTP client with TLS configuration for the OTLP HTTP exporter. +/// +/// We use `reqwest::blocking::Client` instead of the async client because the +/// `opentelemetry_sdk` `BatchLogProcessor` spawns a dedicated OS thread that uses +/// `futures_executor::block_on()` rather than tokio. When the async reqwest client's +/// timeout calls `tokio::time::sleep()`, it panics with "no reactor running". fn build_http_client( tls: &OtelTlsConfig, codex_home: &Path, -) -> Result> { - let mut builder = - reqwest::Client::builder().timeout(resolve_otlp_timeout(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)); +) -> Result> { + // Wrap in block_in_place because reqwest::blocking::Client creates its own + // internal tokio runtime, which would panic if built directly from an async context. + tokio::task::block_in_place(|| build_http_client_inner(tls, codex_home)) +} + +fn build_http_client_inner( + tls: &OtelTlsConfig, + codex_home: &Path, +) -> Result> { + let mut builder = reqwest::blocking::Client::builder() + .timeout(resolve_otlp_timeout(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)); if let Some(path) = tls.ca_certificate.as_ref() { let (pem, location) = read_bytes(codex_home, path)?; @@ -197,7 +212,10 @@ fn build_http_client( location.display() )) })?; - builder = builder.add_root_certificate(certificate); + // Disable built-in root certificates and use only our custom CA + builder = builder + .tls_built_in_root_certs(false) + .add_root_certificate(certificate); } match (&tls.client_certificate, &tls.client_private_key) { @@ -212,7 +230,7 @@ fn build_http_client( key_location.display() )) })?; - builder = builder.identity(identity); + builder = builder.identity(identity).https_only(true); } (Some(_), None) | (None, Some(_)) => { return Err(config_error( From 82090803d9205ba9f7b0b85793dc5c68d63f7cb2 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 6 Dec 2025 10:16:47 -0800 Subject: [PATCH 10/94] fix: exec-server stream was erroring for large requests (#7654) Previous to this change, large `EscalateRequest` payloads exceeded the kernel send buffer, causing our single `sendmsg(2)` call (with attached FDs) to be split and retried without proper control handling; this led to `EINVAL`/broken pipe in the `handle_escalate_session_respects_run_in_sandbox_decision()` test when using an `env` with large contents. **Before:** `AsyncSocket::send_with_fds()` called `send_json_message()`, which called `send_message_bytes()`, which made one `socket.sendmsg()` call followed by additional `socket.send()` calls, as necessary: https://github.com/openai/codex/blob/2e4a40252157751765dff176b35c692df8a9fb4e/codex-rs/exec-server/src/posix/socket.rs#L198-L209 **After:** `AsyncSocket::send_with_fds()` now calls `send_stream_frame()`, which calls `send_stream_chunk()` one or more times. Each call to `send_stream_chunk()` calls `socket.sendmsg()`. In the previous implementation, the subsequent `socket.send()` writes had no control information associated with them, whereas in the new `send_stream_chunk()` implementation, a fresh `MsgHdr` (using `with_control()`, as appropriate) is created for `socket.sendmsg()` each time. Additionally, with this PR, stream sending attaches `SCM_RIGHTS` only on the first chunk, and omits control data when there are no FDs, allowing oversized payloads to deliver correctly while preserving FD limits and error checks. --- .../exec-server/src/posix/escalate_server.rs | 8 +- codex-rs/exec-server/src/posix/socket.rs | 165 ++++++++++-------- 2 files changed, 100 insertions(+), 73 deletions(-) diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index b71142d5b14..72934607a36 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -258,12 +258,18 @@ mod tests { }), )); + let mut env = HashMap::new(); + for i in 0..10 { + let value = "A".repeat(1024); + env.insert(format!("CODEX_TEST_VAR{i}"), value); + } + client .send(EscalateRequest { file: PathBuf::from("/bin/echo"), argv: vec!["echo".to_string()], workdir: PathBuf::from("/tmp"), - env: HashMap::new(), + env, }) .await?; diff --git a/codex-rs/exec-server/src/posix/socket.rs b/codex-rs/exec-server/src/posix/socket.rs index 92c93dcc7d6..35292367a6b 100644 --- a/codex-rs/exec-server/src/posix/socket.rs +++ b/codex-rs/exec-server/src/posix/socket.rs @@ -171,42 +171,24 @@ async fn read_frame_payload( unreachable!("loop exits only after returning payload") } -fn send_message_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { - if fds.len() > MAX_FDS_PER_MESSAGE { +fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { + let control = make_control_message(fds)?; + let payload = [IoSlice::new(data)]; + let msg = if control.is_empty() { + MsgHdr::new().with_buffers(&payload) + } else { + MsgHdr::new().with_buffers(&payload).with_control(&control) + }; + let written = socket.sendmsg(&msg, 0)?; + if written != data.len() { return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("too many fds: {}", fds.len()), + std::io::ErrorKind::WriteZero, + format!( + "short datagram write: wrote {written} bytes out of {}", + data.len() + ), )); } - let mut frame = Vec::with_capacity(LENGTH_PREFIX_SIZE + data.len()); - frame.extend_from_slice(&encode_length(data.len())?); - frame.extend_from_slice(data); - - let mut control = vec![0u8; control_space_for_fds(fds.len())]; - unsafe { - let cmsg = control.as_mut_ptr().cast::(); - (*cmsg).cmsg_len = libc::CMSG_LEN(size_of::() as c_uint * fds.len() as c_uint) as _; - (*cmsg).cmsg_level = libc::SOL_SOCKET; - (*cmsg).cmsg_type = libc::SCM_RIGHTS; - let data_ptr = libc::CMSG_DATA(cmsg).cast::(); - for (i, fd) in fds.iter().enumerate() { - data_ptr.add(i).write(fd.as_raw_fd()); - } - } - - let payload = [IoSlice::new(&frame)]; - let msg = MsgHdr::new().with_buffers(&payload).with_control(&control); - let mut sent = socket.sendmsg(&msg, 0)?; - while sent < frame.len() { - let bytes = socket.send(&frame[sent..])?; - if bytes == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - "socket closed while sending frame payload", - )); - } - sent += bytes; - } Ok(()) } @@ -220,24 +202,16 @@ fn encode_length(len: usize) -> std::io::Result<[u8; LENGTH_PREFIX_SIZE]> { Ok(len_u32.to_le_bytes()) } -pub(crate) fn send_json_message( - socket: &Socket, - msg: T, - fds: &[OwnedFd], -) -> std::io::Result<()> { - let data = serde_json::to_vec(&msg)?; - send_message_bytes(socket, &data, fds) -} - -fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { +fn make_control_message(fds: &[OwnedFd]) -> std::io::Result> { if fds.len() > MAX_FDS_PER_MESSAGE { - return Err(std::io::Error::new( + Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("too many fds: {}", fds.len()), - )); - } - let mut control = vec![0u8; control_space_for_fds(fds.len())]; - if !fds.is_empty() { + )) + } else if fds.is_empty() { + Ok(Vec::new()) + } else { + let mut control = vec![0u8; control_space_for_fds(fds.len())]; unsafe { let cmsg = control.as_mut_ptr().cast::(); (*cmsg).cmsg_len = @@ -249,20 +223,8 @@ fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io data_ptr.add(i).write(fd.as_raw_fd()); } } + Ok(control) } - let payload = [IoSlice::new(data)]; - let msg = MsgHdr::new().with_buffers(&payload).with_control(&control); - let written = socket.sendmsg(&msg, 0)?; - if written != data.len() { - return Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - format!( - "short datagram write: wrote {written} bytes out of {}", - data.len() - ), - )); - } - Ok(()) } fn receive_datagram_bytes(socket: &Socket) -> std::io::Result<(Vec, Vec)> { @@ -308,11 +270,11 @@ impl AsyncSocket { msg: T, fds: &[OwnedFd], ) -> std::io::Result<()> { - self.inner - .async_io(Interest::WRITABLE, |socket| { - send_json_message(socket, &msg, fds) - }) - .await + let payload = serde_json::to_vec(&msg)?; + let mut frame = Vec::with_capacity(LENGTH_PREFIX_SIZE + payload.len()); + frame.extend_from_slice(&encode_length(payload.len())?); + frame.extend_from_slice(&payload); + send_stream_frame(&self.inner, &frame, fds).await } pub async fn receive_with_fds Deserialize<'de>>( @@ -343,6 +305,54 @@ impl AsyncSocket { } } +async fn send_stream_frame( + socket: &AsyncFd, + frame: &[u8], + fds: &[OwnedFd], +) -> std::io::Result<()> { + let mut written = 0; + let mut include_fds = !fds.is_empty(); + while written < frame.len() { + let mut guard = socket.writable().await?; + let result = guard.try_io(|inner| { + send_stream_chunk(inner.get_ref(), &frame[written..], fds, include_fds) + }); + let bytes_written = match result { + Ok(bytes_written) => bytes_written?, + Err(_would_block) => continue, + }; + if bytes_written == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "socket closed while sending frame payload", + )); + } + written += bytes_written; + include_fds = false; + } + Ok(()) +} + +fn send_stream_chunk( + socket: &Socket, + frame: &[u8], + fds: &[OwnedFd], + include_fds: bool, +) -> std::io::Result { + let control = if include_fds { + make_control_message(fds)? + } else { + Vec::new() + }; + let payload = [IoSlice::new(frame)]; + let msg = if control.is_empty() { + MsgHdr::new().with_buffers(&payload) + } else { + MsgHdr::new().with_buffers(&payload).with_control(&control) + }; + socket.sendmsg(&msg, 0) +} + pub(crate) struct AsyncDatagramSocket { inner: AsyncFd, } @@ -433,6 +443,17 @@ mod tests { Ok(()) } + #[tokio::test] + async fn async_socket_handles_large_payload() -> std::io::Result<()> { + let (server, client) = AsyncSocket::pair()?; + let payload = vec![b'A'; 10_000]; + let receive_task = tokio::spawn(async move { server.receive::>().await }); + client.send(payload.clone()).await?; + let received_payload = receive_task.await.unwrap()?; + assert_eq!(payload, received_payload); + Ok(()) + } + #[tokio::test] async fn async_datagram_sockets_round_trip_messages() -> std::io::Result<()> { let (server, client) = AsyncDatagramSocket::pair()?; @@ -450,19 +471,19 @@ mod tests { } #[test] - fn send_message_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { - let (socket, _peer) = Socket::pair(Domain::UNIX, Type::STREAM, None)?; + fn send_datagram_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { + let (socket, _peer) = Socket::pair(Domain::UNIX, Type::DGRAM, None)?; let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?; - let err = send_message_bytes(&socket, b"hello", &fds).unwrap_err(); + let err = send_datagram_bytes(&socket, b"hi", &fds).unwrap_err(); assert_eq!(std::io::ErrorKind::InvalidInput, err.kind()); Ok(()) } #[test] - fn send_datagram_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { - let (socket, _peer) = Socket::pair(Domain::UNIX, Type::DGRAM, None)?; + fn send_stream_chunk_rejects_excessive_fd_counts() -> std::io::Result<()> { + let (socket, _peer) = Socket::pair(Domain::UNIX, Type::STREAM, None)?; let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?; - let err = send_datagram_bytes(&socket, b"hi", &fds).unwrap_err(); + let err = send_stream_chunk(&socket, b"hello", &fds, true).unwrap_err(); assert_eq!(std::io::ErrorKind::InvalidInput, err.kind()); Ok(()) } From 315b1e957d985ee314679ba0c9b4c38ee780f90e Mon Sep 17 00:00:00 2001 From: Jay Sabva <94957904+JaySabva@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:47:18 +0530 Subject: [PATCH 11/94] docs: fix documentation of rmcp client flag (#7665) ## Summary - Updated the rmcp client flag's documentation in config.md file - changed it from `experimental_use_rmcp_client` to `rmcp_client` --- docs/config.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/docs/config.md b/docs/config.md index 3b06d730196..0ba711f02c8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -464,10 +464,11 @@ http_headers = { "HEADER_NAME" = "HEADER_VALUE" } env_http_headers = { "HEADER_NAME" = "ENV_VAR" } ``` -Streamable HTTP connections always use the experimental Rust MCP client under the hood, so expect occasional rough edges. OAuth login flows are gated on the `experimental_use_rmcp_client = true` flag: +Streamable HTTP connections always use the experimental Rust MCP client under the hood, so expect occasional rough edges. OAuth login flows are gated on the `rmcp_client = true` flag: ```toml -experimental_use_rmcp_client = true +[features] +rmcp_client = true ``` After enabling it, run `codex mcp login ` when the server supports OAuth. @@ -489,17 +490,6 @@ disabled_tools = ["search"] When both `enabled_tools` and `disabled_tools` are specified, Codex first restricts the server to the allow-list and then removes any tools that appear in the deny-list. -#### Experimental RMCP client - -This flag enables OAuth support for streamable HTTP servers. - -```toml -experimental_use_rmcp_client = true - -[mcp_servers.server_name] -… -``` - #### MCP CLI commands ```shell From 9a74228c662b99ea0f4030c55eb781745294e63e Mon Sep 17 00:00:00 2001 From: Jay Sabva <94957904+JaySabva@users.noreply.github.com> Date: Sun, 7 Dec 2025 06:21:07 +0530 Subject: [PATCH 12/94] docs: Remove experimental_use_rmcp_client from config (#7672) Removed experimental Rust MCP client option from config. --- docs/example-config.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/example-config.md b/docs/example-config.md index 6061dc88301..f5b3c629042 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -226,12 +226,6 @@ enable_experimental_windows_sandbox = false # Experimental toggles (legacy; prefer [features]) ################################################################################ -# Use experimental unified exec tool. Default: false -experimental_use_unified_exec_tool = false - -# Use experimental Rust MCP client (enables OAuth for HTTP MCP). Default: false -experimental_use_rmcp_client = false - # Include apply_patch via freeform editing path (affects default tool set). Default: false experimental_use_freeform_apply_patch = false @@ -319,8 +313,6 @@ experimental_use_freeform_apply_patch = false # chatgpt_base_url = "https://chatgpt.com/backend-api/" # experimental_compact_prompt_file = "compact_prompt.txt" # include_apply_patch_tool = false -# experimental_use_unified_exec_tool = false -# experimental_use_rmcp_client = false # experimental_use_freeform_apply_patch = false # experimental_sandbox_command_assessment = false # tools_web_search = false From b2cb05d562050f6e59b5641e56441b425c0ef54c Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 6 Dec 2025 18:57:08 -0800 Subject: [PATCH 13/94] docs: point dev checks to just (#7673) Update install and contributing guides to use the root justfile helpers (`just fmt`, `just fix -p `, and targeted tests) instead of the older cargo fmt/clippy/test instructions that have been in place since 459363e17b. This matches the justfile relocation to the repo root in 952d6c946 and the current lint/test workflow for CI (see `.github/workflows/rust-ci.yml`). --- docs/contributing.md | 2 +- docs/install.md | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index fc3d5ce836c..983d64e6dc4 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -25,7 +25,7 @@ If you want to add a new feature or change the behavior of an existing one, plea - Fill in the PR template (or include similar information) - **What? Why? How?** - Include a link to a bug report or enhancement request in the issue tracker -- Run **all** checks locally (`cargo test && cargo clippy --tests && cargo fmt -- --config imports_granularity=Item`). CI failures that could have been caught locally slow down the process. +- Run **all** checks locally. Use the root `just` helpers so you stay consistent with the rest of the workspace: `just fmt`, `just fix -p ` for the crate you touched, and the relevant tests (e.g., `cargo test -p codex-tui` or `just test` if you need a full sweep). CI failures that could have been caught locally slow down the process. - Make sure your branch is up-to-date with `main` and that you have resolved merge conflicts. - Mark the PR as **Ready for review** only when you believe it is in a merge-able state. diff --git a/docs/install.md b/docs/install.md index 724a524e3ba..b54b74f16c1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -24,6 +24,10 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source "$HOME/.cargo/env" rustup component add rustfmt rustup component add clippy +# Install helper tools used by the workspace justfile: +cargo install just +# Optional: install nextest for the `just test` helper (or use `cargo test --all-features` as a fallback) +cargo install cargo-nextest # Build Codex. cargo build @@ -31,10 +35,14 @@ cargo build # Launch the TUI with a sample prompt. cargo run --bin codex -- "explain this codebase to me" -# After making changes, ensure the code is clean. -cargo fmt -- --config imports_granularity=Item -cargo clippy --tests +# After making changes, use the root justfile helpers (they default to codex-rs): +just fmt +just fix -p -# Run the tests. -cargo test +# Run the relevant tests (project-specific is fastest), for example: +cargo test -p codex-tui +# If you have cargo-nextest installed, `just test` runs the full suite: +just test +# Otherwise, fall back to: +cargo test --all-features ``` From 7386e2efbc0696326d382a7c7f754bf02f448d00 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 6 Dec 2025 21:46:07 -0800 Subject: [PATCH 14/94] fix: clear out space on ubuntu runners before running Rust tests (#7678) When I put up https://github.com/openai/codex/pull/7617 for review, initially I started seeing failures on the `ubuntu-24.04` runner used for Rust test runs for the `x86_64-unknown-linux-gnu` architecture. Chat suggested a number of things that could be removed to save space, which seems to help. --- .github/workflows/rust-ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 08c39db69da..f2620dcb7ff 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -369,6 +369,22 @@ jobs: steps: - uses: actions/checkout@v6 + + # We have been running out of space when running this job on Linux for + # x86_64-unknown-linux-gnu, so remove some unnecessary dependencies. + - name: Remove unnecessary dependencies to save space + if: ${{ startsWith(matrix.runner, 'ubuntu') }} + shell: bash + run: | + set -euo pipefail + sudo rm -rf \ + /usr/local/lib/android \ + /usr/share/dotnet \ + /usr/local/share/boost \ + /usr/local/lib/node_modules \ + /opt/ghc + sudo apt-get remove -y docker.io docker-compose podman buildah + - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} From 3c087e8fdadcbf0310fe2d36b972bd9476a0fb37 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 6 Dec 2025 22:11:07 -0800 Subject: [PATCH 15/94] fix: ensure macOS CI runners for Rust tests include recent Homebrew fixes (#7680) As noted in the code comment, we introduced a key fix for `brew` in https://github.com/Homebrew/brew/pull/21157 that Codex needs, but it has not hit stable yet, so we update our CI job to use latest `brew` from `origin/main`. This is necessary for the new integration tests introduced in https://github.com/openai/codex/pull/7617. --- .github/workflows/rust-ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index f2620dcb7ff..354b403920d 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -385,6 +385,28 @@ jobs: /opt/ghc sudo apt-get remove -y docker.io docker-compose podman buildah + # Ensure brew includes this fix so that brew's shellenv.sh loads + # cleanly in the Codex sandbox (it is frequently eval'd via .zprofile + # for Brew users, including the macOS runners on GitHub): + # + # https://github.com/Homebrew/brew/pull/21157 + # + # Once brew 5.0.5 is released and is the default on macOS runners, this + # step can be removed. + - name: Upgrade brew + if: ${{ startsWith(matrix.runner, 'macos') }} + shell: bash + run: | + set -euo pipefail + brew --version + git -C "$(brew --repo)" fetch origin + git -C "$(brew --repo)" checkout main + git -C "$(brew --repo)" reset --hard origin/main + export HOMEBREW_UPDATE_TO_TAG=0 + brew update + brew upgrade + brew --version + - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} From 3c3d3d1adcace87fca7b0e0b9f9b6bef4a8dff72 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 6 Dec 2025 22:39:38 -0800 Subject: [PATCH 16/94] fix: add integration tests for codex-exec-mcp-server with execpolicy (#7617) This PR introduces integration tests that run [codex-shell-tool-mcp](https://www.npmjs.com/package/@openai/codex-shell-tool-mcp) as a user would. Note that this requires running our fork of Bash, so we introduce a [DotSlash](https://dotslash-cli.com/) file for `bash` so that we can run the integration tests on multiple platforms without having to check the binaries into the repository. (As noted in the DotSlash file, it is slightly more heavyweight than necessary, which may be worth addressing as disk space in CI is limited: https://github.com/openai/codex/pull/7678.) To start, this PR adds two tests: - `list_tools()` makes the `list_tools` request to the MCP server and verifies we get the expected response - `accept_elicitation_for_prompt_rule()` defines a `prefix_rule()` with `decision="prompt"` and verifies the elicitation flow works as expected Though the `accept_elicitation_for_prompt_rule()` test **only works on Linux**, as this PR reveals that there are currently issues when running the Bash fork in a read-only sandbox on Linux. This will have to be fixed in a follow-up PR. Incidentally, getting this test run to correctly on macOS also requires a recent fix we made to `brew` that hasn't hit a mainline release yet, so getting CI green in this PR required https://github.com/openai/codex/pull/7680. --- .github/workflows/rust-ci.yml | 13 ++ codex-rs/Cargo.lock | 16 ++ codex-rs/Cargo.toml | 1 + codex-rs/exec-server/Cargo.toml | 4 + codex-rs/exec-server/src/lib.rs | 3 + codex-rs/exec-server/src/posix.rs | 2 + codex-rs/exec-server/src/posix/mcp.rs | 2 +- codex-rs/exec-server/tests/all.rs | 3 + codex-rs/exec-server/tests/common/Cargo.toml | 16 ++ codex-rs/exec-server/tests/common/lib.rs | 167 ++++++++++++++++++ .../tests/suite/accept_elicitation.rs | 131 ++++++++++++++ codex-rs/exec-server/tests/suite/bash | 75 ++++++++ .../exec-server/tests/suite/list_tools.rs | 76 ++++++++ codex-rs/exec-server/tests/suite/mod.rs | 8 + 14 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 codex-rs/exec-server/tests/all.rs create mode 100644 codex-rs/exec-server/tests/common/Cargo.toml create mode 100644 codex-rs/exec-server/tests/common/lib.rs create mode 100644 codex-rs/exec-server/tests/suite/accept_elicitation.rs create mode 100755 codex-rs/exec-server/tests/suite/bash create mode 100644 codex-rs/exec-server/tests/suite/list_tools.rs create mode 100644 codex-rs/exec-server/tests/suite/mod.rs diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 354b403920d..0be45540c1c 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -407,6 +407,19 @@ jobs: brew upgrade brew --version + # Some integration tests rely on DotSlash being installed. + # See https://github.com/openai/codex/pull/7617. + - name: Install DotSlash + uses: facebook/install-dotslash@v2 + + - name: Pre-fetch DotSlash artifacts + # The Bash wrapper is not available on Windows. + if: ${{ !startsWith(matrix.runner, 'windows') }} + shell: bash + run: | + set -euo pipefail + dotslash -- fetch exec-server/tests/suite/bash + - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b77cf01b02e..ea1eec8f836 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1256,11 +1256,14 @@ name = "codex-exec-server" version = "0.0.0" dependencies = [ "anyhow", + "assert_cmd", "async-trait", "clap", "codex-core", "codex-execpolicy", + "exec_server_test_support", "libc", + "maplit", "path-absolutize", "pretty_assertions", "rmcp", @@ -1273,6 +1276,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "which", ] [[package]] @@ -2501,6 +2505,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exec_server_test_support" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "codex-core", + "rmcp", + "serde_json", + "tokio", +] + [[package]] name = "eyre" version = "0.6.12" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d3d3c36c3ef..0587ec1d464 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -96,6 +96,7 @@ codex-utils-readiness = { path = "utils/readiness" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } +exec_server_test_support = { path = "exec-server/tests/common" } mcp-types = { path = "mcp-types" } mcp_test_support = { path = "mcp-server/tests/common" } diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index ab6ca80a120..a0bd5349343 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -56,5 +56,9 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } [dev-dependencies] +assert_cmd = { workspace = true } +exec_server_test_support = { workspace = true } +maplit = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } +which = { workspace = true } diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index adec09d4dec..62f7bbccca9 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -6,3 +6,6 @@ pub use posix::main_execve_wrapper; #[cfg(unix)] pub use posix::main_mcp_server; + +#[cfg(unix)] +pub use posix::ExecResult; diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 16da5885f53..1a4b0a0e1fc 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -82,6 +82,8 @@ mod mcp_escalation_policy; mod socket; mod stopwatch; +pub use mcp::ExecResult; + /// Default value of --execve option relative to the current executable. /// Note this must match the name of the binary as specified in Cargo.toml. const CODEX_EXECVE_WRAPPER_EXE_NAME: &str = "codex-execve-wrapper"; diff --git a/codex-rs/exec-server/src/posix/mcp.rs b/codex-rs/exec-server/src/posix/mcp.rs index bbbddc22e61..1376d46b721 100644 --- a/codex-rs/exec-server/src/posix/mcp.rs +++ b/codex-rs/exec-server/src/posix/mcp.rs @@ -54,7 +54,7 @@ pub struct ExecParams { pub login: Option, } -#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] pub struct ExecResult { pub exit_code: i32, pub output: String, diff --git a/codex-rs/exec-server/tests/all.rs b/codex-rs/exec-server/tests/all.rs new file mode 100644 index 00000000000..7e136e4cce2 --- /dev/null +++ b/codex-rs/exec-server/tests/all.rs @@ -0,0 +1,3 @@ +// Single integration test binary that aggregates all test modules. +// The submodules live in `tests/suite/`. +mod suite; diff --git a/codex-rs/exec-server/tests/common/Cargo.toml b/codex-rs/exec-server/tests/common/Cargo.toml new file mode 100644 index 00000000000..ba7d2af0a17 --- /dev/null +++ b/codex-rs/exec-server/tests/common/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "exec_server_test_support" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "lib.rs" + +[dependencies] +assert_cmd = { workspace = true } +anyhow = { workspace = true } +codex-core = { workspace = true } +rmcp = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/codex-rs/exec-server/tests/common/lib.rs b/codex-rs/exec-server/tests/common/lib.rs new file mode 100644 index 00000000000..c6df5c32c7c --- /dev/null +++ b/codex-rs/exec-server/tests/common/lib.rs @@ -0,0 +1,167 @@ +use codex_core::MCP_SANDBOX_STATE_NOTIFICATION; +use codex_core::SandboxState; +use codex_core::protocol::SandboxPolicy; +use rmcp::ClientHandler; +use rmcp::ErrorData as McpError; +use rmcp::RoleClient; +use rmcp::Service; +use rmcp::model::ClientCapabilities; +use rmcp::model::ClientInfo; +use rmcp::model::CreateElicitationRequestParam; +use rmcp::model::CreateElicitationResult; +use rmcp::model::CustomClientNotification; +use rmcp::model::ElicitationAction; +use rmcp::service::RunningService; +use rmcp::transport::ConfigureCommandExt; +use rmcp::transport::TokioChildProcess; +use serde_json::json; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; +use tokio::process::Command; + +pub fn create_transport

(codex_home: P) -> anyhow::Result +where + P: AsRef, +{ + let mcp_executable = assert_cmd::Command::cargo_bin("codex-exec-mcp-server")?; + let execve_wrapper = assert_cmd::Command::cargo_bin("codex-execve-wrapper")?; + let bash = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("tests") + .join("suite") + .join("bash"); + + let transport = + TokioChildProcess::new(Command::new(mcp_executable.get_program()).configure(|cmd| { + cmd.arg("--bash").arg(bash); + cmd.arg("--execve").arg(execve_wrapper.get_program()); + cmd.env("CODEX_HOME", codex_home.as_ref()); + + // Important: pipe stdio so rmcp can speak JSON-RPC over stdin/stdout + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + + // Optional but very helpful while debugging: + cmd.stderr(Stdio::inherit()); + }))?; + + Ok(transport) +} + +pub async fn write_default_execpolicy

(policy: &str, codex_home: P) -> anyhow::Result<()> +where + P: AsRef, +{ + let policy_dir = codex_home.as_ref().join("policy"); + tokio::fs::create_dir_all(&policy_dir).await?; + tokio::fs::write(policy_dir.join("default.codexpolicy"), policy).await?; + Ok(()) +} + +pub async fn notify_readable_sandbox( + sandbox_cwd: P, + codex_linux_sandbox_exe: Option, + service: &RunningService, +) -> anyhow::Result<()> +where + P: AsRef, + S: Service + ClientHandler, +{ + let sandbox_state = SandboxState { + sandbox_policy: SandboxPolicy::ReadOnly, + codex_linux_sandbox_exe, + sandbox_cwd: sandbox_cwd.as_ref().to_path_buf(), + }; + send_sandbox_notification(sandbox_state, service).await +} + +pub async fn notify_writable_sandbox_only_one_folder( + writable_folder: P, + codex_linux_sandbox_exe: Option, + service: &RunningService, +) -> anyhow::Result<()> +where + P: AsRef, + S: Service + ClientHandler, +{ + let sandbox_state = SandboxState { + sandbox_policy: SandboxPolicy::WorkspaceWrite { + // Note that sandbox_cwd will already be included as a writable root + // when the sandbox policy is expanded. + writable_roots: vec![], + network_access: false, + // Disable writes to temp dir because this is a test, so + // writable_folder is likely also under /tmp and we want to be + // strict about what is writable. + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, + codex_linux_sandbox_exe, + sandbox_cwd: writable_folder.as_ref().to_path_buf(), + }; + send_sandbox_notification(sandbox_state, service).await +} + +async fn send_sandbox_notification( + sandbox_state: SandboxState, + service: &RunningService, +) -> anyhow::Result<()> +where + S: Service + ClientHandler, +{ + let sandbox_state_notification = CustomClientNotification::new( + MCP_SANDBOX_STATE_NOTIFICATION, + Some(serde_json::to_value(sandbox_state)?), + ); + service + .send_notification(sandbox_state_notification.into()) + .await?; + Ok(()) +} + +pub struct InteractiveClient { + pub elicitations_to_accept: HashSet, + pub elicitation_requests: Arc>>, +} + +impl ClientHandler for InteractiveClient { + fn get_info(&self) -> ClientInfo { + let capabilities = ClientCapabilities::builder().enable_elicitation().build(); + ClientInfo { + capabilities, + ..Default::default() + } + } + + fn create_elicitation( + &self, + request: CreateElicitationRequestParam, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + self.elicitation_requests + .lock() + .unwrap() + .push(request.clone()); + + let accept = self.elicitations_to_accept.contains(&request.message); + async move { + if accept { + Ok(CreateElicitationResult { + action: ElicitationAction::Accept, + content: Some(json!({ "approve": true })), + }) + } else { + Ok(CreateElicitationResult { + action: ElicitationAction::Decline, + content: None, + }) + } + } + } +} diff --git a/codex-rs/exec-server/tests/suite/accept_elicitation.rs b/codex-rs/exec-server/tests/suite/accept_elicitation.rs new file mode 100644 index 00000000000..2093f9a5777 --- /dev/null +++ b/codex-rs/exec-server/tests/suite/accept_elicitation.rs @@ -0,0 +1,131 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use std::borrow::Cow; +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::Result; +use codex_exec_server::ExecResult; +use exec_server_test_support::InteractiveClient; +use exec_server_test_support::create_transport; +use exec_server_test_support::notify_readable_sandbox; +use exec_server_test_support::write_default_execpolicy; +use maplit::hashset; +use pretty_assertions::assert_eq; +use rmcp::ServiceExt; +use rmcp::model::CallToolRequestParam; +use rmcp::model::CallToolResult; +use rmcp::model::CreateElicitationRequestParam; +use rmcp::model::object; +use serde_json::json; +use std::os::unix::fs::symlink; +use tempfile::TempDir; + +/// Verify that when using a read-only sandbox and an execpolicy that prompts, +/// the proper elicitation is sent. Upon auto-approving the elicitation, the +/// command should be run privileged outside the sandbox. +#[tokio::test(flavor = "current_thread")] +async fn accept_elicitation_for_prompt_rule() -> Result<()> { + // Configure a stdio transport that will launch the MCP server using + // $CODEX_HOME with an execpolicy that prompts for `git init` commands. + let codex_home = TempDir::new()?; + write_default_execpolicy( + r#" +# Create a rule with `decision = "prompt"` to exercise the elicitation flow. +prefix_rule( + pattern = ["git", "init"], + decision = "prompt", + match = [ + "git init ." + ], +) +"#, + codex_home.as_ref(), + ) + .await?; + let transport = create_transport(codex_home.as_ref())?; + + // Create an MCP client that approves expected elicitation messages. + let project_root = TempDir::new()?; + let git = which::which("git")?; + let project_root_path = project_root.path().canonicalize().unwrap(); + let expected_elicitation_message = format!( + "Allow agent to run `{} init .` in `{}`?", + git.display(), + project_root_path.display() + ); + let elicitation_requests: Arc>> = Default::default(); + let client = InteractiveClient { + elicitations_to_accept: hashset! { expected_elicitation_message.clone() }, + elicitation_requests: elicitation_requests.clone(), + }; + + // Start the MCP server. + let service: rmcp::service::RunningService = + client.serve(transport).await?; + + // Notify the MCP server about the current sandbox state before making any + // `shell` tool calls. + let linux_sandbox_exe_folder = TempDir::new()?; + let codex_linux_sandbox_exe = if cfg!(target_os = "linux") { + let codex_linux_sandbox_exe = linux_sandbox_exe_folder.path().join("codex-linux-sandbox"); + let codex_cli = assert_cmd::Command::cargo_bin("codex")? + .get_program() + .to_os_string(); + let codex_cli_path = std::path::PathBuf::from(codex_cli); + symlink(&codex_cli_path, &codex_linux_sandbox_exe)?; + Some(codex_linux_sandbox_exe) + } else { + None + }; + notify_readable_sandbox(&project_root_path, codex_linux_sandbox_exe, &service).await?; + + // Call the shell tool and verify that an elicitation was created and + // auto-approved. + let CallToolResult { + content, is_error, .. + } = service + .call_tool(CallToolRequestParam { + name: Cow::Borrowed("shell"), + arguments: Some(object(json!( + { + "command": "git init .", + "workdir": project_root_path.to_string_lossy(), + } + ))), + }) + .await?; + let tool_call_content = content + .first() + .expect("expected non-empty content") + .as_text() + .expect("expected text content"); + let ExecResult { + exit_code, output, .. + } = serde_json::from_str::(&tool_call_content.text)?; + let git_init_succeeded = format!( + "Initialized empty Git repository in {}/.git/\n", + project_root_path.display() + ); + // Normally, this would be an exact match, but it might include extra output + // if `git config set advice.defaultBranchName false` has not been set. + assert!( + output.contains(&git_init_succeeded), + "expected output `{output}` to contain `{git_init_succeeded}`" + ); + assert_eq!(exit_code, 0, "command should succeed"); + assert_eq!(is_error, Some(false), "command should succeed"); + assert!( + project_root_path.join(".git").is_dir(), + "git repo should exist" + ); + + let elicitation_messages = elicitation_requests + .lock() + .unwrap() + .iter() + .map(|r| r.message.clone()) + .collect::>(); + assert_eq!(vec![expected_elicitation_message], elicitation_messages); + + Ok(()) +} diff --git a/codex-rs/exec-server/tests/suite/bash b/codex-rs/exec-server/tests/suite/bash new file mode 100755 index 00000000000..5f5d1e55939 --- /dev/null +++ b/codex-rs/exec-server/tests/suite/bash @@ -0,0 +1,75 @@ +#!/usr/bin/env dotslash + +// This is an instance of the fork of Bash that we bundle with +// https://www.npmjs.com/package/@openai/codex-shell-tool-mcp. +// Fetching the prebuilt version via DotSlash makes it easier to write +// integration tests for the MCP server. +// +// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for +// multiple platforms, but we could save a bit of space by making arch-specific +// artifacts available in the GitHub releases and referencing those here. +{ + "name": "codex-bash", + "platforms": { + // macOS 13 builds (and therefore x86_64) were dropped in + // https://github.com/openai/codex/pull/7295, so we only provide an + // Apple Silicon build for now. + "macos-aarch64": { + "size": 37003612, + "hash": "blake3", + "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", + "format": "tar.gz", + "path": "package/vendor/aarch64-apple-darwin/bash/macos-15/bash", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.65.0", + "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" + } + ] + }, + // Note the `musl` parts of the Linux paths are misleading: the Bash + // binaries are actually linked against `glibc`, but the + // `codex-execve-wrapper` that invokes them is linked against `musl`. + "linux-x86_64": { + "size": 37003612, + "hash": "blake3", + "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", + "format": "tar.gz", + "path": "package/vendor/x86_64-unknown-linux-musl/bash/ubuntu-24.04/bash", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.65.0", + "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" + } + ] + }, + "linux-aarch64": { + "size": 37003612, + "hash": "blake3", + "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", + "format": "tar.gz", + "path": "package/vendor/aarch64-unknown-linux-musl/bash/ubuntu-24.04/bash", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.65.0", + "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" + } + ] + }, + } +} diff --git a/codex-rs/exec-server/tests/suite/list_tools.rs b/codex-rs/exec-server/tests/suite/list_tools.rs new file mode 100644 index 00000000000..17505c7613c --- /dev/null +++ b/codex-rs/exec-server/tests/suite/list_tools.rs @@ -0,0 +1,76 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use std::borrow::Cow; +use std::fs; +use std::sync::Arc; + +use anyhow::Result; +use exec_server_test_support::create_transport; +use pretty_assertions::assert_eq; +use rmcp::ServiceExt; +use rmcp::model::Tool; +use rmcp::model::object; +use serde_json::json; +use tempfile::TempDir; + +/// Verify the list_tools call to the MCP server returns the expected response. +#[tokio::test(flavor = "current_thread")] +async fn list_tools() -> Result<()> { + let codex_home = TempDir::new()?; + let policy_dir = codex_home.path().join("policy"); + fs::create_dir_all(&policy_dir)?; + fs::write( + policy_dir.join("default.codexpolicy"), + r#"prefix_rule(pattern=["ls"], decision="prompt")"#, + )?; + let transport = create_transport(codex_home.path())?; + + let service = ().serve(transport).await?; + let tools = service.list_tools(Default::default()).await?.tools; + assert_eq!( + vec![Tool { + name: Cow::Borrowed("shell"), + title: None, + description: Some(Cow::Borrowed( + "Runs a shell command and returns its output. You MUST provide the workdir as an absolute path." + )), + input_schema: Arc::new(object(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "command": { + "description": "The bash string to execute.", + "type": "string", + }, + "login": { + "description": "Launch Bash with -lc instead of -c: defaults to true.", + "nullable": true, + "type": "boolean", + }, + "timeout_ms": { + "description": "The timeout for the command in milliseconds.", + "format": "uint64", + "minimum": 0, + "nullable": true, + "type": "integer", + }, + "workdir": { + "description": "The working directory to execute the command in. Must be an absolute path.", + "type": "string", + }, + }, + "required": [ + "command", + "workdir", + ], + "title": "ExecParams", + "type": "object", + }))), + output_schema: None, + annotations: None, + icons: None, + meta: None + }], + tools + ); + + Ok(()) +} diff --git a/codex-rs/exec-server/tests/suite/mod.rs b/codex-rs/exec-server/tests/suite/mod.rs new file mode 100644 index 00000000000..3a94f58579e --- /dev/null +++ b/codex-rs/exec-server/tests/suite/mod.rs @@ -0,0 +1,8 @@ +// TODO(mbolin): Get this test working on Linux. Currently, it fails with: +// +// > Error: Mcp error: -32603: sandbox error: sandbox denied exec error, +// > exit code: 1, stdout: , stderr: Error: failed to send handshake datagram +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +mod accept_elicitation; +#[cfg(any(all(target_os = "macos", target_arch = "aarch64"), target_os = "linux"))] +mod list_tools; From 53a486f7ea370dfc34a1b46214b7456d69e5ee3c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 7 Dec 2025 09:47:48 -0800 Subject: [PATCH 17/94] Add remote models feature flag (#7648) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/codex-api/src/endpoint/models.rs | 5 +- .../codex-api/tests/models_integration.rs | 17 +- codex-rs/core/src/codex.rs | 10 + codex-rs/core/src/features.rs | 8 + .../core/src/openai_models/model_family.rs | 7 +- .../core/src/openai_models/models_manager.rs | 22 ++- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 183 ++++++++++++++++++ codex-rs/protocol/src/openai_models.rs | 64 ++++-- codex-rs/protocol/src/protocol.rs | 2 +- 10 files changed, 292 insertions(+), 27 deletions(-) create mode 100644 codex-rs/core/tests/suite/remote_models.rs diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs index fec8d7f292e..39f7b30c321 100644 --- a/codex-rs/codex-api/src/endpoint/models.rs +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -181,12 +181,13 @@ mod tests { "display_name": "gpt-test", "description": "desc", "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium", "high"], + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}, {"effort": "high", "description": "high"}], "shell_type": "shell_command", "visibility": "list", "minimal_client_version": [0, 99, 0], "supported_in_api": true, - "priority": 1 + "priority": 1, + "upgrade": null, })) .unwrap(), ], diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 3b4077f5342..fff9c53f7a9 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -10,6 +10,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; use http::HeaderMap; use http::Method; use wiremock::Mock; @@ -57,15 +58,25 @@ async fn models_client_hits_models_endpoint() { description: Some("desc".to_string()), default_reasoning_level: ReasoningEffort::Medium, supported_reasoning_levels: vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, + ReasoningEffortPreset { + effort: ReasoningEffort::Low, + description: ReasoningEffort::Low.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: ReasoningEffort::High.to_string(), + }, ], shell_type: ConfigShellToolType::ShellCommand, visibility: ModelVisibility::List, minimal_client_version: ClientVersion(0, 1, 0), supported_in_api: true, priority: 1, + upgrade: None, }], }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 89435ee6d0b..cc758eaedf2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1470,6 +1470,16 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv let mut previous_context: Option> = Some(sess.new_turn(SessionSettingsUpdate::default()).await); + if config.features.enabled(Feature::RemoteModels) + && let Err(err) = sess + .services + .models_manager + .refresh_available_models(&config.model_provider) + .await + { + error!("failed to refresh available models: {err}"); + } + // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 1d775360c41..69442815e70 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -54,6 +54,8 @@ pub enum Feature { WindowsSandbox, /// Remote compaction enabled (only for ChatGPT auth) RemoteCompaction, + /// Refresh remote models and emit AppReady once the list is available. + RemoteModels, /// Allow model to call multiple tools in parallel (only for models supporting it). ParallelToolCalls, /// Experimental skills injection (CLI flag-driven). @@ -333,6 +335,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: true, }, + FeatureSpec { + id: Feature::RemoteModels, + key: "remote_models", + stage: Stage::Experimental, + default_enabled: false, + }, FeatureSpec { id: Feature::ParallelToolCalls, key: "parallel", diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 6ee18ad9e34..507e1a48d92 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -291,6 +291,7 @@ mod tests { use super::*; use codex_protocol::openai_models::ClientVersion; use codex_protocol::openai_models::ModelVisibility; + use codex_protocol::openai_models::ReasoningEffortPreset; fn remote(slug: &str, effort: ReasoningEffort, shell: ConfigShellToolType) -> ModelInfo { ModelInfo { @@ -298,12 +299,16 @@ mod tests { display_name: slug.to_string(), description: Some(format!("{slug} desc")), default_reasoning_level: effort, - supported_reasoning_levels: vec![effort], + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort, + description: effort.to_string(), + }], shell_type: shell, visibility: ModelVisibility::List, minimal_client_version: ClientVersion(0, 1, 0), supported_in_api: true, priority: 1, + upgrade: None, } } diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 22edf04ffe5..55c11f4554b 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -36,7 +36,6 @@ impl ModelsManager { } } - // do not use this function yet. It's work in progress. pub async fn refresh_available_models( &self, provider: &ModelProviderInfo, @@ -47,16 +46,21 @@ impl ModelsManager { let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); + let mut client_version = env!("CARGO_PKG_VERSION"); + if client_version == "0.0.0" { + client_version = "99.99.99"; + } let response = client - .list_models(env!("CARGO_PKG_VERSION"), HeaderMap::new()) + .list_models(client_version, HeaderMap::new()) .await .map_err(map_api_error)?; let models = response.models; *self.remote_models.write().await = models.clone(); + let available_models = self.build_available_models().await; { let mut available_models_guard = self.available_models.write().await; - *available_models_guard = self.build_available_models().await; + *available_models_guard = available_models; } Ok(models) } @@ -75,8 +79,11 @@ impl ModelsManager { async fn build_available_models(&self) -> Vec { let mut available_models = self.remote_models.read().await.clone(); available_models.sort_by(|a, b| b.priority.cmp(&a.priority)); - let mut model_presets: Vec = - available_models.into_iter().map(Into::into).collect(); + let mut model_presets: Vec = available_models + .into_iter() + .map(Into::into) + .filter(|preset: &ModelPreset| preset.show_in_picker) + .collect(); if let Some(default) = model_presets.first_mut() { default.is_default = true; } @@ -103,12 +110,13 @@ mod tests { "display_name": display, "description": format!("{display} desc"), "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium"], + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}], "shell_type": "shell_command", "visibility": "list", "minimal_client_version": [0, 1, 0], "supported_in_api": true, - "priority": priority + "priority": priority, + "upgrade": null, })) .expect("valid model") } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e2d78004a5d..2112cbb7aaa 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -41,6 +41,7 @@ mod otel; mod prompt_caching; mod quota_exceeded; mod read_file; +mod remote_models; mod resume; mod review; mod rmcp_client; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs new file mode 100644 index 00000000000..4178ed1c2a1 --- /dev/null +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -0,0 +1,183 @@ +#![cfg(not(target_os = "windows"))] +// unified exec is not supported on Windows OS +use std::sync::Arc; + +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_models_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use serde_json::json; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use wiremock::BodyPrintLimit; +use wiremock::MockServer; + +const REMOTE_MODEL_SLUG: &str = "codex-test"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let remote_model = ModelInfo { + slug: REMOTE_MODEL_SLUG.to_string(), + display_name: "Remote Test".to_string(), + description: Some("A remote model that requires the test shell".to_string()), + default_reasoning_level: ReasoningEffort::Medium, + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::UnifiedExec, + visibility: ModelVisibility::List, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority: 1, + upgrade: None, + }; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::RemoteModels); + config.model = "gpt-5.1".to_string(); + }); + + let TestCodex { + codex, + cwd, + config, + conversation_manager, + .. + } = builder.build(&server).await?; + + let models_manager = conversation_manager.get_models_manager(); + let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await; + + assert_eq!(available_model.model, REMOTE_MODEL_SLUG); + + let requests = models_mock.requests(); + assert_eq!( + requests.len(), + 1, + "expected a single /models refresh request for the remote models feature" + ); + assert_eq!(requests[0].url.path(), "/v1/models"); + + let family = models_manager + .construct_model_family(REMOTE_MODEL_SLUG, &config) + .await; + assert_eq!(family.shell_type, ConfigShellToolType::UnifiedExec); + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(REMOTE_MODEL_SLUG.to_string()), + effort: None, + summary: None, + }) + .await?; + + let call_id = "call"; + let args = json!({ + "cmd": "/bin/echo call", + "yield_time_ms": 250, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run call".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REMOTE_MODEL_SLUG.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin_event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), + _ => None, + }) + .await; + + assert_eq!(begin_event.source, ExecCommandSource::UnifiedExecStartup); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + Ok(()) +} + +async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(model) = { + let guard = manager.available_models.read().await; + guard.iter().find(|model| model.model == slug).cloned() + } { + return model; + } + if Instant::now() >= deadline { + panic!("timed out waiting for the remote model {slug} to appear"); + } + sleep(Duration::from_millis(25)).await; + } +} diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 02d50627ca7..0804811a3fa 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use strum::IntoEnumIterator; use strum_macros::Display; use strum_macros::EnumIter; use ts_rs::TS; @@ -36,7 +37,7 @@ pub enum ReasoningEffort { } /// A reasoning effort option that can be surfaced for a model. -#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] pub struct ReasoningEffortPreset { /// Effort level that the model supports. pub effort: ReasoningEffort, @@ -123,7 +124,7 @@ pub struct ModelInfo { #[serde(default)] pub description: Option, pub default_reasoning_level: ReasoningEffort, - pub supported_reasoning_levels: Vec, + pub supported_reasoning_levels: Vec, pub shell_type: ConfigShellToolType, #[serde(default = "default_visibility")] pub visibility: ModelVisibility, @@ -132,6 +133,8 @@ pub struct ModelInfo { pub supported_in_api: bool, #[serde(default)] pub priority: i32, + #[serde(default)] + pub upgrade: Option, } /// Response wrapper for `/models`. @@ -149,22 +152,57 @@ impl From for ModelPreset { fn from(info: ModelInfo) -> Self { ModelPreset { id: info.slug.clone(), - model: info.slug, + model: info.slug.clone(), display_name: info.display_name, description: info.description.unwrap_or_default(), default_reasoning_effort: info.default_reasoning_level, - supported_reasoning_efforts: info - .supported_reasoning_levels - .into_iter() - .map(|level| ReasoningEffortPreset { - effort: level, - // todo: add description for each reasoning effort - description: level.to_string(), - }) - .collect(), + supported_reasoning_efforts: info.supported_reasoning_levels.clone(), is_default: false, // default is the highest priority available model - upgrade: None, // no upgrade available (todo: think about it) + upgrade: info.upgrade.as_ref().map(|upgrade_slug| ModelUpgrade { + id: upgrade_slug.clone(), + reasoning_effort_mapping: reasoning_effort_mapping_from_presets( + &info.supported_reasoning_levels, + ), + migration_config_key: info.slug.clone(), + }), show_in_picker: info.visibility == ModelVisibility::List, } } } + +fn reasoning_effort_mapping_from_presets( + presets: &[ReasoningEffortPreset], +) -> Option> { + if presets.is_empty() { + return None; + } + + // Map every canonical effort to the closest supported effort for the new model. + let supported: Vec = presets.iter().map(|p| p.effort).collect(); + let mut map = HashMap::new(); + for effort in ReasoningEffort::iter() { + let nearest = nearest_effort(effort, &supported); + map.insert(effort, nearest); + } + Some(map) +} + +fn effort_rank(effort: ReasoningEffort) -> i32 { + match effort { + ReasoningEffort::None => 0, + ReasoningEffort::Minimal => 1, + ReasoningEffort::Low => 2, + ReasoningEffort::Medium => 3, + ReasoningEffort::High => 4, + ReasoningEffort::XHigh => 5, + } +} + +fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { + let target_rank = effort_rank(target); + supported + .iter() + .copied() + .min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs()) + .unwrap_or(target) +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 225a622dcce..89b5fd315a6 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1348,7 +1348,7 @@ pub struct ReviewLineRange { pub end: u32, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[derive(Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ExecCommandSource { Agent, From acb8ed493f588911da02b3fe0ac2e552d8b717f0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 8 Dec 2025 02:49:51 -0600 Subject: [PATCH 18/94] Fixed regression for chat endpoint; missing tools name caused litellm proxy to crash (#7724) This PR addresses https://github.com/openai/codex/issues/7051 --- codex-rs/core/src/tools/spec.rs | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index e72becd8821..89a71b0edc4 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -804,10 +804,16 @@ pub(crate) fn create_tools_json_for_chat_completions_api( } if let Some(map) = tool.as_object_mut() { + let name = map + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); // Remove "type" field as it is not needed in chat completions. map.remove("type"); Some(json!({ "type": "function", + "name": name, "function": map, })) } else { @@ -2083,4 +2089,58 @@ Examples of valid command strings: }) ); } + + #[test] + fn chat_tools_include_top_level_name() { + let mut properties = BTreeMap::new(); + properties.insert("foo".to_string(), JsonSchema::String { description: None }); + let tools = vec![ToolSpec::Function(ResponsesApiTool { + name: "demo".to_string(), + description: "A demo tool".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: None, + }, + })]; + + let responses_json = create_tools_json_for_responses_api(&tools).unwrap(); + assert_eq!( + responses_json, + vec![json!({ + "type": "function", + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + })] + ); + + let tools_json = create_tools_json_for_chat_completions_api(&tools).unwrap(); + + assert_eq!( + tools_json, + vec![json!({ + "type": "function", + "name": "demo", + "function": { + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + } + })] + ); + } } From 57ba9fa100f8589a49f0bc65b4050f50d73bf30b Mon Sep 17 00:00:00 2001 From: Robby He <448523760@qq.com> Date: Mon, 8 Dec 2025 17:20:23 +0800 Subject: [PATCH 19/94] =?UTF-8?q?fix(doc):=20TOML=20otel=20exporter=20exam?= =?UTF-8?q?ple=20=E2=80=94=20multi-line=20inline=20table=20is=20inv?= =?UTF-8?q?=E2=80=A6=20(#7669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …alid (#7668) The `otel` exporter example in `docs/config.md` is misleading and will cause the configuration parser to fail if copied verbatim. Summary ------- The example uses a TOML inline table but spreads the inline-table braces across multiple lines. TOML inline tables must be contained on a single line (`key = { a = 1, b = 2 }`); placing newlines inside the braces triggers a parse error in most TOML parsers and prevents Codex from starting. Reproduction ------------ 1. Paste the snippet below into `~/.codex/config.toml` (or your project config). 2. Run `codex` (or the command that loads the config). 3. The process will fail to start with a TOML parse error similar to: ```text Error loading config.toml: TOML parse error at line 55, column 27 | 55 | exporter = { otlp-http = { | ^ newlines are unsupported in inline tables, expected nothing ``` Problematic snippet (as currently shown in the docs) --------------------------------------------------- ```toml [otel] exporter = { otlp-http = { endpoint = "https://otel.example.com/v1/logs", protocol = "binary", headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } }} ``` Recommended fixes ------------------ ```toml [otel.exporter."otlp-http"] endpoint = "https://otel.example.com/v1/logs" protocol = "binary" [otel.exporter."otlp-http".headers] "x-otlp-api-key" = "${OTLP_TOKEN}" ``` Or, keep an inline table but write it on one line (valid but less readable): ```toml [otel] exporter = { "otlp-http" = { endpoint = "https://otel.example.com/v1/logs", protocol = "binary", headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } } } ``` --- docs/config.md | 39 ++++++++++++++++++--------------------- docs/example-config.md | 42 ++++++++++++++++++++---------------------- 2 files changed, 38 insertions(+), 43 deletions(-) diff --git a/docs/config.md b/docs/config.md index 0ba711f02c8..b7d44142aae 100644 --- a/docs/config.md +++ b/docs/config.md @@ -615,12 +615,12 @@ Set `otel.exporter` to control where events go: endpoint, protocol, and headers your collector expects: ```toml - [otel] - exporter = { otlp-http = { - endpoint = "https://otel.example.com/v1/logs", - protocol = "binary", - headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } - }} + [otel.exporter."otlp-http"] + endpoint = "https://otel.example.com/v1/logs" + protocol = "binary" + + [otel.exporter."otlp-http".headers] + "x-otlp-api-key" = "${OTLP_TOKEN}" ``` - `otlp-grpc` – streams OTLP log records over gRPC. Provide the endpoint and any @@ -628,27 +628,24 @@ Set `otel.exporter` to control where events go: ```toml [otel] - exporter = { otlp-grpc = { - endpoint = "https://otel.example.com:4317", - headers = { "x-otlp-meta" = "abc123" } - }} + exporter = { otlp-grpc = {endpoint = "https://otel.example.com:4317",headers = { "x-otlp-meta" = "abc123" }}} ``` Both OTLP exporters accept an optional `tls` block so you can trust a custom CA or enable mutual TLS. Relative paths are resolved against `~/.codex/`: ```toml -[otel] -exporter = { otlp-http = { - endpoint = "https://otel.example.com/v1/logs", - protocol = "binary", - headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }, - tls = { - ca-certificate = "certs/otel-ca.pem", - client-certificate = "/etc/codex/certs/client.pem", - client-private-key = "/etc/codex/certs/client-key.pem", - } -}} +[otel.exporter."otlp-http"] +endpoint = "https://otel.example.com/v1/logs" +protocol = "binary" + +[otel.exporter."otlp-http".headers] +"x-otlp-api-key" = "${OTLP_TOKEN}" + +[otel.exporter."otlp-http".tls] +ca-certificate = "certs/otel-ca.pem" +client-certificate = "/etc/codex/certs/client.pem" +client-private-key = "/etc/codex/certs/client-key.pem" ``` If the exporter is `none` nothing is written anywhere; otherwise you must run or point to your diff --git a/docs/example-config.md b/docs/example-config.md index f5b3c629042..1f326ac14b8 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -341,30 +341,28 @@ environment = "dev" exporter = "none" # Example OTLP/HTTP exporter configuration -# [otel] -# exporter = { otlp-http = { -# endpoint = "https://otel.example.com/v1/logs", -# protocol = "binary", # "binary" | "json" -# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } -# }} +# [otel.exporter."otlp-http"] +# endpoint = "https://otel.example.com/v1/logs" +# protocol = "binary" # "binary" | "json" + +# [otel.exporter."otlp-http".headers] +# "x-otlp-api-key" = "${OTLP_TOKEN}" # Example OTLP/gRPC exporter configuration -# [otel] -# exporter = { otlp-grpc = { -# endpoint = "https://otel.example.com:4317", -# headers = { "x-otlp-meta" = "abc123" } -# }} +# [otel.exporter."otlp-grpc"] +# endpoint = "https://otel.example.com:4317", +# headers = { "x-otlp-meta" = "abc123" } # Example OTLP exporter with mutual TLS -# [otel] -# exporter = { otlp-http = { -# endpoint = "https://otel.example.com/v1/logs", -# protocol = "binary", -# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }, -# tls = { -# ca-certificate = "certs/otel-ca.pem", -# client-certificate = "/etc/codex/certs/client.pem", -# client-private-key = "/etc/codex/certs/client-key.pem", -# } -# }} +# [otel.exporter."otlp-http"] +# endpoint = "https://otel.example.com/v1/logs" +# protocol = "binary" + +# [otel.exporter."otlp-http".headers] +# "x-otlp-api-key" = "${OTLP_TOKEN}" + +# [otel.exporter."otlp-http".tls] +# ca-certificate = "certs/otel-ca.pem" +# client-certificate = "/etc/codex/certs/client.pem" +# client-private-key = "/etc/codex/certs/client-key.pem" ``` From 98923654d008f74a85b1af4174fe252439fdb359 Mon Sep 17 00:00:00 2001 From: gameofby Date: Mon, 8 Dec 2025 17:23:21 +0800 Subject: [PATCH 20/94] fix: refine the warning message and docs for deprecated tools config (#7685) Issue #7661 revealed that users are confused by deprecation warnings like: > `tools.web_search` is deprecated. Use `web_search_request` instead. This message misleadingly suggests renaming the config key from `web_search` to `web_search_request`, when the actual required change is to **move and rename the configuration from the `[tools]` section to the `[features]` section**. This PR clarifies the warning messages and documentation to make it clear that deprecated `[tools]` configurations should be moved to `[features]`. Changes made: - Updated deprecation warning format in `codex-rs/core/src/codex.rs:520` to include `[features].` prefix - Updated corresponding test expectations in `codex-rs/core/tests/suite/deprecation_notice.rs:39` - Improved documentation in `docs/config.md` to clarify upfront that `[tools]` options are deprecated in favor of `[features]` --- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/tests/suite/deprecation_notice.rs | 2 +- docs/config.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cc758eaedf2..c33904e2fde 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -536,7 +536,7 @@ impl Session { for (alias, feature) in config.features.legacy_feature_usages() { let canonical = feature.key(); - let summary = format!("`{alias}` is deprecated. Use `{canonical}` instead."); + let summary = format!("`{alias}` is deprecated. Use `[features].{canonical}` instead."); let details = if alias == canonical { None } else { diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 4e240f0a077..bab715ebd80 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -36,7 +36,7 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() let DeprecationNoticeEvent { summary, details } = notice; assert_eq!( summary, - "`use_experimental_unified_exec_tool` is deprecated. Use `unified_exec` instead." + "`use_experimental_unified_exec_tool` is deprecated. Use `[features].unified_exec` instead." .to_string(), ); assert_eq!( diff --git a/docs/config.md b/docs/config.md index b7d44142aae..08ff2aa349c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -350,6 +350,8 @@ Though using this option may also be necessary if you try to use Codex in enviro ### tools.\* +These `[tools]` configuration options are deprecated. Use `[features]` instead (see [Feature flags](#feature-flags)). + Use the optional `[tools]` table to toggle built-in tools that the agent may call. `web_search` stays off unless you opt in, while `view_image` is now enabled by default: ```toml @@ -358,8 +360,6 @@ web_search = true # allow Codex to issue first-party web searches without prom view_image = false # disable image uploads (they're enabled by default) ``` -`web_search` is deprecated; use the `web_search_request` feature flag instead. - The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow. ### approval_presets From 056c2ee2765aa951f74a7e8ad5f40e503baa2712 Mon Sep 17 00:00:00 2001 From: Pavel <19418601+rakleed@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:47:33 +0300 Subject: [PATCH 21/94] fix: update URLs to use HTTPS in model migration prompts (#7705) Update URLs to use HTTPS in model migration prompts Closes #6685 --- codex-rs/tui/src/model_migration.rs | 6 ++++-- ...tui__model_migration__tests__model_migration_prompt.snap | 2 +- ...migration__tests__model_migration_prompt_gpt5_codex.snap | 2 +- ...tion__tests__model_migration_prompt_gpt5_codex_mini.snap | 2 +- ...igration__tests__model_migration_prompt_gpt5_family.snap | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 1f93fd9a4fd..cbce1f1bb01 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -292,7 +292,9 @@ fn gpt_5_1_codex_max_migration_copy() -> ModelMigrationCopy { ), Line::from(vec![ "Learn more at ".into(), - "www.openai.com/index/gpt-5-1-codex-max".cyan().underlined(), + "https://openai.com/index/gpt-5-1-codex-max/" + .cyan() + .underlined(), ".".into(), ]), ], @@ -312,7 +314,7 @@ fn gpt5_migration_copy() -> ModelMigrationCopy { ), Line::from(vec![ "Learn more at ".into(), - "www.openai.com/index/gpt-5-1".cyan().underlined(), + "https://openai.com/index/gpt-5-1/".cyan().underlined(), ".".into(), ]), Line::from(vec!["Press enter to continue".dim()]), diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap index 5b3136803fb..1f95142169b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap @@ -9,7 +9,7 @@ expression: terminal.backend() than its predecessors and capable of long-running project-scale work. - Learn more at www.openai.com/index/gpt-5-1-codex-max. + Learn more at https://openai.com/index/gpt-5-1-codex-max/. Choose how you'd like Codex to proceed. diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap index 5a0ccd9b5b3..52718e5793c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap index 5a0ccd9b5b3..52718e5793c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap index 5a0ccd9b5b3..52718e5793c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue From 701f42b74bed4ba4df93d5be740505a017e105a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:55:06 -0800 Subject: [PATCH 22/94] chore(deps): bump ts-rs from 11.0.1 to 11.1.0 in /codex-rs (#7713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [ts-rs](https://github.com/Aleph-Alpha/ts-rs) from 11.0.1 to 11.1.0.

Release notes

Sourced from ts-rs's releases.

v11.1.0

Today, we're happy to publish a small follow-up to v11.0.1!

This release fixes a nasty build failure when using the format feature. Note: For those that use the format feature, this release bumps the MSRV to 1.88. We'd have preferred to do this in a major release, but felt this was acceptable since the build was broken by one of the dependencies anyway.

New features

TypeScript enums with #[ts(repr(enum))

#[ts(repr(enum)) instructs ts-rs to generate an enum, instead of a type for your rust enum.

#[derive(TS)]
#[ts(repr(enum))]
enum Role {
    User,
    Admin,
}
// will generate `export enum Role { "User", "Admin"
}`

Discriminants are preserved, and you can use the variant's name as discriminant instead using #[ts(repr(enum = name))]

#[ts(optional_fields)] in enums

The #[ts(optional_fields)] attribute can now be applied directly to enums, or even to individual enum variants.

Control over file extensions in imports

Normally, we generate import { Type } from "file" statements. In some scenarios though, it might be necessary to use a .ts or even .js extension instead.
This is now possible by setting the TS_RS_IMPORT_EXTENSION environment variable.

Note: With the introduction of this feature, we deprecate the import-esm cargo feature. It will be removed in a future major release.

Full changelog

New Contributors

Changelog

Sourced from ts-rs's changelog.

11.1.0

Features

  • Add #[ts(repr(enum))] attribute (#425)
  • Add support for #[ts(optional_fields)] in enums and enum variants (#432)
  • Deprecate import-esm cargo feature in favour of RS_RS_IMPORT_EXTENSION (#423)

Fixes

  • Fix bindings for chrono::Duration (#434)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ts-rs&package-manager=cargo&previous-version=11.0.1&new-version=11.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ea1eec8f836..3331ca3b8c2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2561,7 +2561,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.0.8", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3464,7 +3464,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5252,7 +5252,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6937,9 +6937,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ts-rs" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" +checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ "serde_json", "thiserror 2.0.17", @@ -6949,9 +6949,9 @@ dependencies = [ [[package]] name = "ts-rs-macros" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a" +checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", @@ -7433,7 +7433,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] From 7a6d6090d7944a98f30301480381cd1aa7520f62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:58:50 -0800 Subject: [PATCH 23/94] chore(deps): bump derive_more from 2.0.1 to 2.1.0 in /codex-rs (#7714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [derive_more](https://github.com/JelteF/derive_more) from 2.0.1 to 2.1.0.
Release notes

Sourced from derive_more's releases.

2.1.0

Added

  • Support #[display(rename_all = "<casing>")] attribute to change output for implicit naming of unit enum variants or unit structs when deriving Display. (#443)
  • Support #[from_str(rename_all = "<casing>")] attribute for unit enum variants and unit structs when deriving FromStr. (#467)
  • Support Option fields for Error::source() in Error derive. (#459)
  • Support structs with no fields in FromStr derive. (#469)
  • Add PartialEq derive similar to std's one, but considering generics correctly, and implementing ne() method as well. (#473, #475)
  • Add Eq derive similar to std's one, but considering generics correctly. (#479)
  • Proxy-pass #[allow]/#[expect] attributes of the type in Constructor derive. (#477)
  • Support Deref and DerefMut derives for enums. (#485)
  • Support custom error in FromStr derive. (#494)
  • Support custom error in TryInto derive. (#503)
  • Support skipping fields in Add-like, AddAssign-like, Mul-like and MulAssign-like derives. (#472)

Changed

  • The minimum supported Rust version (MSRV) is now Rust 1.81. (#466)
  • Add-like, AddAssign-like, Mul-like and MulAssign-like derives now infer trait bounds for generics structurally (bound field types instead of type parameters directly). (#472)

Fixed

  • Suppress deprecation warnings in generated code. (#454)
  • Silent no-op when #[try_from(repr)] attribute is not specified for TryFrom derive. (#458)
  • Missing trait bounds in AsRef/AsMut derives when associative types are involved. (#474)
  • Erroneous code generated in Try/TryInto derives when Self type is present in the struct or enum definition. (#489)
  • Dependency on unstable feature(error_generic_member_access) in Error derive when using Backtrace on a non-nightly toolchain. (#513)
  • Broken support for #[<display-trait>("default formatting")] attribute without {_variant} being used as default for enum variants without explicit formatting. (#495)

New Contributors

Full Changelog: https://github.com/JelteF/derive_more/compare/v2.0.1...v2.1.0

Changelog

Sourced from derive_more's changelog.

2.1.0 - 2025-12-02

Added

  • Support #[display(rename_all = "<casing>")] attribute to change output for implicit naming of unit enum variants or unit structs when deriving Display. (#443)
  • Support #[from_str(rename_all = "<casing>")] attribute for unit enum variants and unit structs when deriving FromStr. (#467)
  • Support Option fields for Error::source() in Error derive. (#459)
  • Support structs with no fields in FromStr derive. (#469)
  • Add PartialEq derive similar to std's one, but considering generics correctly, and implementing ne() method as well. (#473, #475)
  • Add Eq derive similar to std's one, but considering generics correctly. (#479)
  • Proxy-pass #[allow]/#[expect] attributes of the type in Constructor derive. (#477)
  • Support Deref and DerefMut derives for enums. (#485)
  • Support custom error in FromStr derive. (#494)
  • Support custom error in TryInto derive. (#503)
  • Support skipping fields in Add-like, AddAssign-like, Mul-like and MulAssign-like derives. (#472)

Changed

  • The minimum supported Rust version (MSRV) is now Rust 1.81. (#466)
  • Add-like, AddAssign-like, Mul-like and MulAssign-like derives now infer trait bounds for generics structurally (bound field types instead of type parameters directly). (#472)

Fixed

  • Suppress deprecation warnings in generated code. (#454)
  • Silent no-op when #[try_from(repr)] attribute is not specified for TryFrom derive. (#458)
  • Missing trait bounds in AsRef/AsMut derives when associative types are involved. (#474)
  • Erroneous code generated in Try/TryInto derives when Self type is present in

... (truncated)

Commits
  • c354bad Prepare 2.1.0 release (#521)
  • 983875f Allow using enum-level attributes for non-Display formatting traits as defa...
  • 2d3805b Allow skipping fields for Add/AddAssign/Mul/MulAssign-like derives (#...
  • 1b5d314 Upgrade convert_case requirement from 0.9 to 0.10 version (#520)
  • c32d0a0 Upgrade actions/checkout from 5 to 6 version (#519)
  • 905f5a3 Upgrade convert_case crate from 0.8 to 0.9 version (#517)
  • 8e9104d Support syn::ExprCall and syn::ExprClosure for custom errors (#516, #112)
  • be3edc4 Update compile_fail tests for 1.91 Rust (#515)
  • 929dd41 Support custom error type in TryInto derive (#503, #396)
  • 4fc6827 Remove unstable feature requirement when deriving Backtraced Error (#513,...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=derive_more&package-manager=cargo&previous-version=2.0.1&new-version=2.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3331ca3b8c2..a3ad04346c8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1302,7 +1302,7 @@ dependencies = [ "allocative", "anyhow", "clap", - "derive_more 2.0.1", + "derive_more 2.1.0", "env_logger", "log", "multimap", @@ -1593,7 +1593,7 @@ dependencies = [ "codex-windows-sandbox", "color-eyre", "crossterm", - "derive_more 2.0.1", + "derive_more 2.1.0", "diffy", "dirs", "dunce", @@ -1795,9 +1795,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -2136,11 +2136,11 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ - "derive_more-impl 2.0.1", + "derive_more-impl 2.1.0", ] [[package]] @@ -2158,13 +2158,14 @@ dependencies = [ [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ - "convert_case 0.7.1", + "convert_case 0.10.0", "proc-macro2", "quote", + "rustc_version", "syn 2.0.104", "unicode-xid", ] From 9fa9e3e7bbbe09a363359570896d335a3c4e7624 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:59:19 -0800 Subject: [PATCH 24/94] chore(deps): bump insta from 1.43.2 to 1.44.3 in /codex-rs (#7715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [insta](https://github.com/mitsuhiko/insta) from 1.43.2 to 1.44.3.
Release notes

Sourced from insta's releases.

1.44.3

Release Notes

  • Fix a regression in 1.44.2 where merge conflict detection was too aggressive, incorrectly flagging snapshot content containing ====== or similar patterns as conflicts. #832
  • Fix a regression in 1.42.2 where inline snapshot updates would corrupt the file when code preceded the macro (e.g., let output = assert_snapshot!(...)). #833

Install cargo-insta 1.44.3

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/mitsuhiko/insta/releases/download/1.44.3/cargo-insta-installer.sh
| sh

Install prebuilt binaries via powershell script

powershell -ExecutionPolicy Bypass -c "irm
https://github.com/mitsuhiko/insta/releases/download/1.44.3/cargo-insta-installer.ps1
| iex"

Download cargo-insta 1.44.3

File Platform Checksum
cargo-insta-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
cargo-insta-x86_64-apple-darwin.tar.xz Intel macOS checksum
cargo-insta-x86_64-pc-windows-msvc.zip x64 Windows checksum
cargo-insta-x86_64-unknown-linux-gnu.tar.xz x64 Linux checksum
cargo-insta-x86_64-unknown-linux-musl.tar.xz x64 MUSL Linux checksum

1.44.2

Release Notes

  • Fix a rare backward compatibility issue where inline snapshots using an uncommon legacy format (single-line content stored in multiline raw strings) could fail to match after 1.44.0. #830
  • Handle merge conflicts in snapshot files gracefully. When a snapshot file contains git merge conflict markers, insta now detects them and treats the snapshot as missing, allowing tests to continue and create a new pending snapshot for review. #829
  • Skip nextest_doctest tests when cargo-nextest is not installed. #826
  • Fix functional tests failing under nextest due to inherited NEXTEST_RUN_ID environment variable. #824

Install cargo-insta 1.44.2

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/mitsuhiko/insta/releases/download/1.44.2/cargo-insta-installer.sh
| sh

Install prebuilt binaries via powershell script

powershell -ExecutionPolicy Bypass -c "irm
https://github.com/mitsuhiko/insta/releases/download/1.44.2/cargo-insta-installer.ps1
| iex"
</tr></table>

... (truncated)

Changelog

Sourced from insta's changelog.

1.44.3

  • Fix a regression in 1.44.2 where merge conflict detection was too aggressive, incorrectly flagging snapshot content containing ====== or similar patterns as conflicts. #832
  • Fix a regression in 1.42.2 where inline snapshot updates would corrupt the file when code preceded the macro (e.g., let output = assert_snapshot!(...)). #833

1.44.2

  • Fix a rare backward compatibility issue where inline snapshots using an uncommon legacy format (single-line content stored in multiline raw strings) could fail to match after 1.44.0. #830
  • Handle merge conflicts in snapshot files gracefully. When a snapshot file contains git merge conflict markers, insta now detects them and treats the snapshot as missing, allowing tests to continue and create a new pending snapshot for review. #829
  • Skip nextest_doctest tests when cargo-nextest is not installed. #826
  • Fix functional tests failing under nextest due to inherited NEXTEST_RUN_ID environment variable. #824

1.44.1

  • Add --dnd alias for --disable-nextest-doctest flag to make it easier to silence the deprecation warning. #822
  • Update cargo-dist to 0.30.2 and fix Windows runner to use windows-2022. #821

1.44.0

  • Added non-interactive snapshot review and reject modes for use in non-TTY environments (LLMs, CI pipelines, scripts). cargo insta review --snapshot <path> and cargo insta reject --snapshot <path> now work without a terminal. Enhanced pending-snapshots output with usage instructions and workspace-relative paths. #815
  • Add --disable-nextest-doctest flag to cargo insta test to disable running doctests with nextest. Shows a deprecation warning when nextest is used with doctests without this flag, to prepare cargo insta to no longer run a separate doctest process when using nextest in the future. #803
  • Add ergonomic --test-runner-fallback / --no-test-runner-fallback flags to cargo insta test. #811
  • Apply redactions to snapshot metadata. #813
  • Remove confusing 'previously unseen snapshot' message. #812
  • Speed up JSON float rendering. #806 (@​nyurik)
  • Allow globset version up to 0.4.16. #810 (@​g0hl1n)
  • Improve documentation. #814 (@​tshepang)
  • We no longer trim starting newlines during assertions, which allows asserting the number of leading newlines match. Existing assertions with different leading newlines will pass and print a warning suggesting running with --force-update-snapshots. They may fail in the future. (Note that we still currently allow differing trailing newlines, though may adjust this in the future). #563
Commits
  • dcbb11f Prepare release 1.44.3 (#838)
  • 3b9ec12 Refine test name & description (#837)
  • ee4e1ea Handle unparsable snapshot files gracefully (#836)
  • 778f733 Fix for code before macros, such as let foo = assert_snapshot! (#835)
  • 6cb41af Prepare release 1.44.2 (#831)
  • 8838b2f Handle merge conflicts in snapshot files gracefully (#829)
  • e55ce99 Fix backward compatibility for legacy inline snapshot format (#830)
  • d44dd42 Skip nextest_doctest tests when cargo-nextest is not installed (#826)
  • a711baf Fix functional tests failing under nextest (#824)
  • ba9ea51 Prepare release 1.44.1 (#823)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=insta&package-manager=cargo&previous-version=1.43.2&new-version=1.44.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- codex-rs/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a3ad04346c8..dd393a7029d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3399,9 +3399,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.43.2" +version = "1.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" dependencies = [ "console", "once_cell", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0587ec1d464..21408c240f1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -139,7 +139,7 @@ icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" image = { version = "^0.25.9", default-features = false } indexmap = "2.12.0" -insta = "1.43.2" +insta = "1.44.3" itertools = "0.14.0" keyring = { version = "3.6", default-features = false } landlock = "0.4.1" From 5e888ab48ecff65bb5425c78423026b68d94c78c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:13:51 +0000 Subject: [PATCH 25/94] chore(deps): bump wildmatch from 2.5.0 to 2.6.1 in /codex-rs (#7716) Bumps [wildmatch](https://github.com/becheran/wildmatch) from 2.5.0 to 2.6.1.
Commits
  • ca6568b chore: Release wildmatch version 2.6.1
  • 513c5ab docs: fix broken links
  • fe47b5f chore: use latest mlc version
  • 4d05f9f Merge pull request #30 from arifd/patch-1
  • 26114f7 unify example pattern used in WildMatchPattern examples
  • 32c36f5 chore: Release wildmatch version 2.6.0
  • 4777964 Merge pull request #29 from arifd/prevent-ambiguous-same-single-multi-wildcard
  • 3a5bf1b prevent ambiguous same single multi wildcard
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=wildmatch&package-manager=cargo&previous-version=2.5.0&new-version=2.6.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- codex-rs/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dd393a7029d..3e7f0fd8b0d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -7408,9 +7408,9 @@ dependencies = [ [[package]] name = "wildmatch" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39b7d07a236abaef6607536ccfaf19b396dbe3f5110ddb73d39f4562902ed382" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" [[package]] name = "winapi" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 21408c240f1..2086fbe8976 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -223,7 +223,7 @@ vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" which = "6" -wildmatch = "2.5.0" +wildmatch = "2.6.1" wiremock = "0.6" zeroize = "1.8.2" From cfda44b98bd4174c0cf609e4723d8c372547b2d1 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Mon, 8 Dec 2025 09:35:03 -0800 Subject: [PATCH 26/94] fix wrap behavior for long commands (#7655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before: image after: Screenshot 2025-12-05 at 4 37 14 PM also removes `is_current`, which is deadcode --- codex-rs/tui/src/bottom_pane/command_popup.rs | 2 +- .../tui/src/bottom_pane/file_search_popup.rs | 2 +- .../src/bottom_pane/list_selection_view.rs | 53 +++++++++++++++-- .../src/bottom_pane/selection_popup_common.rs | 58 ++++++++++--------- codex-rs/tui/src/bottom_pane/skill_popup.rs | 2 +- 5 files changed, 83 insertions(+), 34 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 39bbfbd1822..8aca5c4a625 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -182,9 +182,9 @@ impl CommandPopup { GenericDisplayRow { name, match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, display_shortcut: None, description: Some(description), + wrap_indent: None, } }) .collect() diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index 708b0047480..064e4f01370 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -129,9 +129,9 @@ impl WidgetRef for &FileSearchPopup { .indices .as_ref() .map(|v| v.iter().map(|&i| i as usize).collect()), - is_current: false, display_shortcut: None, description: None, + wrap_indent: None, }) .collect() }; 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 d294a472653..b58524185ba 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,7 @@ use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height; use super::selection_popup_common::render_rows; +use unicode_width::UnicodeWidthStr; /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; @@ -192,23 +193,26 @@ impl ListSelectionView { item.name.clone() }; let n = visible_idx + 1; - let display_name = if self.is_searchable { + let wrap_prefix = if self.is_searchable { // The number keys don't work when search is enabled (since we let the // numbers be used for the search query). - format!("{prefix} {name_with_marker}") + format!("{prefix} ") } else { - format!("{prefix} {n}. {name_with_marker}") + format!("{prefix} {n}. ") }; + let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); + let display_name = format!("{wrap_prefix}{name_with_marker}"); let description = is_selected .then(|| item.selected_description.clone()) .flatten() .or_else(|| item.description.clone()); + let wrap_indent = description.is_none().then_some(wrap_prefix_width); GenericDisplayRow { name: display_name, display_shortcut: item.display_shortcut, match_indices: None, - is_current: item.is_current, description, + wrap_indent, } }) }) @@ -558,6 +562,47 @@ mod tests { ); } + #[test] + fn wraps_long_option_without_overflowing_columns() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "Yes, proceed".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again for commands that start with `python -mpre_commit run --files eslint-plugin/no-mixed-const-enum-exports.js`".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Approval".to_string()), + items, + ..Default::default() + }, + tx, + ); + + let rendered = render_lines_with_width(&view, 60); + let command_line = rendered + .lines() + .find(|line| line.contains("python -mpre_commit run")) + .expect("rendered lines should include wrapped command"); + assert!( + command_line.starts_with(" `python -mpre_commit run"), + "wrapped command line should align under the numbered prefix:\n{rendered}" + ); + assert!( + rendered.contains("eslint-plugin/no-") + && rendered.contains("mixed-const-enum-exports.js"), + "long command should not be truncated even when wrapped:\n{rendered}" + ); + } + #[test] fn width_changes_do_not_hide_rows() { let (tx_raw, _rx) = unbounded_channel::(); 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 8042a75b28b..5107ab0ca91 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -19,8 +19,8 @@ pub(crate) struct GenericDisplayRow { pub name: String, pub display_shortcut: Option, pub match_indices: Option>, // indices to bold (char positions) - pub is_current: bool, - pub description: Option, // optional grey text after the name + pub description: Option, // optional grey text after the name + pub wrap_indent: Option, // optional indent for wrapped lines } /// Compute a shared description-column start based on the widest visible name @@ -47,13 +47,30 @@ fn compute_desc_col( desc_col } +/// Determine how many spaces to indent wrapped lines for a row. +fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { + let max_indent = max_width.saturating_sub(1) as usize; + let indent = row.wrap_indent.unwrap_or_else(|| { + if row.description.is_some() { + desc_col + } else { + 0 + } + }); + indent.min(max_indent) +} + /// Build the full display line for a row with the description padded to start /// 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 name_limit = row + .description + .as_ref() + .map(|_| desc_col.saturating_sub(2)) + .unwrap_or(usize::MAX); let mut name_spans: Vec = Vec::with_capacity(row.name.len()); let mut used_width = 0usize; @@ -63,11 +80,12 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { 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 { + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { truncated = true; break; } - used_width += ch_w; + used_width = next_width; if idx_iter.peek().is_some_and(|next| **next == char_idx) { idx_iter.next(); @@ -79,11 +97,12 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { } else { for ch in row.name.chars() { let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - if used_width + ch_w > name_limit { + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { truncated = true; break; } - used_width += ch_w; + used_width = next_width; name_spans.push(ch.to_string().into()); } } @@ -161,24 +180,7 @@ pub(crate) fn render_rows( break; } - let GenericDisplayRow { - name, - match_indices, - display_shortcut, - is_current: _is_current, - description, - } = row; - - let mut full_line = build_full_line( - &GenericDisplayRow { - name: name.clone(), - match_indices: match_indices.clone(), - display_shortcut: *display_shortcut, - is_current: *_is_current, - description: description.clone(), - }, - desc_col, - ); + let mut full_line = build_full_line(row, desc_col); if Some(i) == state.selected_idx { // Match previous behavior: cyan + bold for the selected row. // Reset the style first to avoid inheriting dim from keyboard shortcuts. @@ -190,9 +192,10 @@ pub(crate) fn render_rows( // Wrap with subsequent indent aligned to the description column. use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; + let continuation_indent = wrap_indent(row, desc_col, area.width); let options = RtOptions::new(area.width as usize) .initial_indent(Line::from("")) - .subsequent_indent(Line::from(" ".repeat(desc_col))); + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); let wrapped = word_wrap_line(&full_line, options); // Render the wrapped lines. @@ -256,9 +259,10 @@ pub(crate) fn measure_rows_height( .map(|(_, r)| r) { let full_line = build_full_line(row, desc_col); + let continuation_indent = wrap_indent(row, desc_col, content_width); let opts = RtOptions::new(content_width as usize) .initial_indent(Line::from("")) - .subsequent_indent(Line::from(" ".repeat(desc_col))); + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16); } total.max(1) diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 74c1b137ca1..3e0f79f84bb 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -90,9 +90,9 @@ impl SkillPopup { GenericDisplayRow { name, match_indices: indices, - is_current: false, display_shortcut: None, description: Some(description), + wrap_indent: None, } }) .collect() From c2bdee094658fdbe82c15cd589f18e61b12e1997 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Mon, 8 Dec 2025 09:55:20 -0800 Subject: [PATCH 27/94] proposing execpolicy amendment when prompting due to sandbox denial (#7653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, we only show the “don’t ask again for commands that start with…” option when a command is immediately flagged as needing approval. However, there is another case where we ask for approval: When a command is initially auto-approved to run within sandbox, but it fails to run inside sandbox, we would like to attempt to retry running outside of sandbox. This will require a prompt to the user. This PR addresses this latter case --- codex-rs/core/src/exec_policy.rs | 142 ++++++++++++++---- codex-rs/core/src/tools/runtimes/shell.rs | 3 +- .../core/src/tools/runtimes/unified_exec.rs | 3 +- codex-rs/core/src/tools/sandboxing.rs | 8 + 4 files changed, 121 insertions(+), 35 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 2d1c2efe5e8..6de2967c765 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -34,6 +34,13 @@ const POLICY_DIR_NAME: &str = "policy"; const POLICY_EXTENSION: &str = "codexpolicy"; const DEFAULT_POLICY_FILE: &str = "default.codexpolicy"; +fn is_policy_match(rule_match: &RuleMatch) -> bool { + match rule_match { + RuleMatch::PrefixRuleMatch { .. } => true, + RuleMatch::HeuristicsRuleMatch { .. } => false, + } +} + #[derive(Debug, Error)] pub enum ExecPolicyError { #[error("failed to read execpolicy files from {dir}: {source}")] @@ -147,49 +154,62 @@ pub(crate) async fn append_execpolicy_amendment_and_update( Ok(()) } -/// Returns a proposed execpolicy amendment only when heuristics caused -/// the prompt decision, so we can offer to apply that amendment for future runs. -/// -/// The amendment uses the first command heuristics marked as `Prompt`. If any explicit -/// execpolicy rule also prompts, we return `None` because applying the amendment would not -/// skip that policy requirement. -/// -/// Examples: +/// Derive a proposed execpolicy amendment when a command requires user approval +/// - If any execpolicy rule prompts, return None, because an amendment would not skip that policy requirement. +/// - Otherwise return the first heuristics Prompt. +/// - Examples: /// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`. /// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`. /// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt /// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`. /// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above, /// we return `None` because an execpolicy prompt still applies even if we amend execpolicy to allow ["prog1", "--option1", "arg1"]. -fn proposed_execpolicy_amendment(evaluation: &Evaluation) -> Option { - if evaluation.decision != Decision::Prompt { +fn try_derive_execpolicy_amendment_for_prompt_rules( + matched_rules: &[RuleMatch], +) -> Option { + if matched_rules + .iter() + .any(|rule_match| is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt) + { return None; } - let mut first_prompt_from_heuristics: Option> = None; - for rule_match in &evaluation.matched_rules { - match rule_match { - RuleMatch::HeuristicsRuleMatch { command, decision } => { - if *decision == Decision::Prompt && first_prompt_from_heuristics.is_none() { - first_prompt_from_heuristics = Some(command.clone()); - } - } - _ if rule_match.decision() == Decision::Prompt => { - return None; - } - _ => {} - } + matched_rules + .iter() + .find_map(|rule_match| match rule_match { + RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + } => Some(ExecPolicyAmendment::from(command.clone())), + _ => None, + }) +} + +/// - Note: we only use this amendment when the command fails to run in sandbox and codex prompts the user to run outside the sandbox +/// - The purpose of this amendment is to bypass sandbox for similar commands in the future +/// - If any execpolicy rule matches, return None, because we would already be running command outside the sandbox +fn try_derive_execpolicy_amendment_for_allow_rules( + matched_rules: &[RuleMatch], +) -> Option { + if matched_rules.iter().any(is_policy_match) { + return None; } - first_prompt_from_heuristics.map(ExecPolicyAmendment::from) + matched_rules + .iter() + .find_map(|rule_match| match rule_match { + RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Allow, + } => Some(ExecPolicyAmendment::from(command.clone())), + _ => None, + }) } /// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision. fn derive_prompt_reason(evaluation: &Evaluation) -> Option { evaluation.matched_rules.iter().find_map(|rule_match| { - if !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) - && rule_match.decision() == Decision::Prompt - { + if is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt { Some(PROMPT_REASON.to_string()) } else { None @@ -215,10 +235,6 @@ pub(crate) async fn create_exec_approval_requirement_for_command( }; let policy = exec_policy.read().await; let evaluation = policy.check_multiple(commands.iter(), &heuristics_fallback); - let has_policy_allow = evaluation.matched_rules.iter().any(|rule_match| { - !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) - && rule_match.decision() == Decision::Allow - }); match evaluation.decision { Decision::Forbidden => ExecApprovalRequirement::Forbidden { @@ -233,7 +249,7 @@ pub(crate) async fn create_exec_approval_requirement_for_command( ExecApprovalRequirement::NeedsApproval { reason: derive_prompt_reason(&evaluation), proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { - proposed_execpolicy_amendment(&evaluation) + try_derive_execpolicy_amendment_for_prompt_rules(&evaluation.matched_rules) } else { None }, @@ -241,7 +257,15 @@ pub(crate) async fn create_exec_approval_requirement_for_command( } } Decision::Allow => ExecApprovalRequirement::Skip { - bypass_sandbox: has_policy_allow, + // Bypass sandbox if execpolicy allows the command + bypass_sandbox: evaluation.matched_rules.iter().any(|rule_match| { + is_policy_match(rule_match) && rule_match.decision() == Decision::Allow + }), + proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { + try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules) + } else { + None + }, }, } } @@ -730,4 +754,56 @@ prefix_rule(pattern=["rm"], decision="forbidden") } ); } + + #[tokio::test] + async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { + let command = vec!["echo".to_string(), "safe".to_string()]; + + let requirement = create_exec_approval_requirement_for_command( + &Arc::new(RwLock::new(Policy::empty())), + &Features::with_defaults(), + &command, + AskForApproval::OnRequest, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + } + ); + } + + #[tokio::test] + async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() { + let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + let command = vec!["echo".to_string(), "safe".to_string()]; + + let requirement = create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::OnRequest, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + } + ); + } } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 2af095ee92b..50b6a6785ad 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -133,7 +133,8 @@ impl Approvable for ShellRuntime { || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { - bypass_sandbox: true + bypass_sandbox: true, + .. } ) { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 4c1cbb83eca..d21e6de1e24 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -154,7 +154,8 @@ impl Approvable for UnifiedExecRuntime<'_> { || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { - bypass_sandbox: true + bypass_sandbox: true, + .. } ) { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 94c81043ccf..5e69696923d 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -95,6 +95,9 @@ pub(crate) enum ExecApprovalRequirement { /// The first attempt should skip sandboxing (e.g., when explicitly /// greenlit by policy). bypass_sandbox: bool, + /// Proposed execpolicy amendment to skip future approvals for similar commands + /// Only applies if the command fails to run in sandbox and codex prompts the user to run outside the sandbox. + proposed_execpolicy_amendment: Option, }, /// Approval required for this tool call. NeedsApproval { @@ -114,6 +117,10 @@ impl ExecApprovalRequirement { proposed_execpolicy_amendment: Some(prefix), .. } => Some(prefix), + Self::Skip { + proposed_execpolicy_amendment: Some(prefix), + .. + } => Some(prefix), _ => None, } } @@ -140,6 +147,7 @@ pub(crate) fn default_exec_approval_requirement( } else { ExecApprovalRequirement::Skip { bypass_sandbox: false, + proposed_execpolicy_amendment: None, } } } From da983c176116cf38920e3809f93a0345694fc597 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 8 Dec 2025 18:42:09 +0000 Subject: [PATCH 28/94] feat: add is-mutating detection for shell command handler (#7729) --- codex-rs/core/src/tools/handlers/shell.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index c3ef590e132..7d13c90fa02 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -148,6 +148,20 @@ impl ToolHandler for ShellCommandHandler { matches!(payload, ToolPayload::Function { .. }) } + fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::Function { arguments } = &invocation.payload else { + return true; + }; + + serde_json::from_str::(arguments) + .map(|params| { + let shell = invocation.session.user_shell(); + let command = shell.derive_exec_args(¶ms.command, params.login.unwrap_or(true)); + !is_known_safe_command(&command) + }) + .unwrap_or(true) + } + async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, From 585f75bd5aed80ee1766582a0a9c62ef14d1b934 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 8 Dec 2025 11:04:49 -0800 Subject: [PATCH 29/94] Make the device auth instructions more clear. (#7745) - [x] Make the device auth instructions more clear. --- codex-rs/login/src/device_code_auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index a121de7ebd4..d9e7d90ce28 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -141,7 +141,7 @@ fn print_device_code_prompt(code: &str) { println!( "\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\ \nFollow these steps to sign in with ChatGPT using device code authorization:\n\ -\n1. Open this link in your browser\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\ +\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\ \n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\ \n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n", version = env!("CARGO_PKG_VERSION"), From 28e7218c0b7e76462d33eec4c7bdd18f48e1dc94 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 8 Dec 2025 11:13:50 -0800 Subject: [PATCH 30/94] feat: linux codesign with sigstore (#7674) ### Summary Linux codesigning with sigstore and test run output at https://github.com/openai/codex/actions/runs/19994328162?pr=7662. Sigstore is one of the few ways for codesigning for linux platform. Linux is open sourced and therefore binary/dist validation comes with the build itself instead of a central authority like Windows or Mac. Alternative here is to use GPG which again a public key included with the bundle for validation. Advantage with Sigstore is that we do not have to create a private key for signing but rather with[ keyless signing](https://docs.sigstore.dev/cosign/signing/overview/). This should be sufficient for us at this point and if we want to we can support GPG in the future. --- .github/actions/linux-code-sign/action.yml | 44 ++++++++++++++++++++++ .github/workflows/rust-release.yml | 20 ++++++++++ 2 files changed, 64 insertions(+) create mode 100644 .github/actions/linux-code-sign/action.yml diff --git a/.github/actions/linux-code-sign/action.yml b/.github/actions/linux-code-sign/action.yml new file mode 100644 index 00000000000..5a117b0805f --- /dev/null +++ b/.github/actions/linux-code-sign/action.yml @@ -0,0 +1,44 @@ +name: linux-code-sign +description: Sign Linux artifacts with cosign. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + artifacts-dir: + description: Absolute path to the directory containing built binaries to sign. + required: true + +runs: + using: composite + steps: + - name: Install cosign + uses: sigstore/cosign-installer@v3.7.0 + + - name: Cosign Linux artifacts + shell: bash + env: + COSIGN_EXPERIMENTAL: "1" + COSIGN_YES: "true" + COSIGN_OIDC_CLIENT_ID: "sigstore" + COSIGN_OIDC_ISSUER: "https://oauth2.sigstore.dev/auth" + run: | + set -euo pipefail + + dest="${{ inputs.artifacts-dir }}" + if [[ ! -d "$dest" ]]; then + echo "Destination $dest does not exist" + exit 1 + fi + + for binary in codex codex-responses-api-proxy; do + artifact="${dest}/${binary}" + if [[ ! -f "$artifact" ]]; then + echo "Binary $artifact not found" + exit 1 + fi + + cosign sign-blob \ + --yes \ + --bundle "${artifact}.sigstore" \ + "$artifact" + done diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 14f8aa03279..c3e9eeef9a3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -50,6 +50,9 @@ jobs: name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 + permissions: + contents: read + id-token: write defaults: run: working-directory: codex-rs @@ -100,6 +103,13 @@ jobs: - name: Cargo build run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + - if: ${{ contains(matrix.target, 'linux') }} + name: Cosign Linux artifacts + uses: ./.github/actions/linux-code-sign + with: + target: ${{ matrix.target }} + artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash @@ -283,6 +293,11 @@ jobs: cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi + if [[ "${{ matrix.target }}" == *linux* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" + fi + - if: ${{ matrix.runner == 'windows-11-arm' }} name: Install zstd shell: powershell @@ -321,6 +336,11 @@ jobs: continue fi + # Don't try to compress signature bundles. + if [[ "$base" == *.sigstore ]]; then + continue + fi + # Create per-binary tar.gz tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" From 4a3e9ed88d0d4a2624df8dfc97b600aac9b28b3e Mon Sep 17 00:00:00 2001 From: Takuto Yuki Date: Tue, 9 Dec 2025 04:21:15 +0900 Subject: [PATCH 31/94] fix(tui): add missing Ctrl+n/Ctrl+p support to ListSelectionView (#7629) ## Summary Extend Ctrl+n/Ctrl+p navigation support to selection popups (model picker, approval mode, etc.) This is a follow-up to #7530, which added Ctrl+n/Ctrl+p navigation to the textarea. The same keybindings were missing from `ListSelectionView`, causing inconsistent behavior when navigating selection popups. ## Related - #7530 - feat(tui): map Ctrl-P/N to arrow navigation in textarea ## Changes - Added Ctrl+n as alternative to Down arrow in selection popups - Added Ctrl+p as alternative to Up arrow in selection popups - Added unit tests for the new keybindings ## Test Plan - [x] `cargo test -p codex-tui list_selection_view` - all tests pass - [x] Manual testing: verified Ctrl+n/p navigation works in model selection popup --------- Co-authored-by: Eric Traut --- .../src/bottom_pane/list_selection_view.rs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) 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 b58524185ba..26a32a42e1c 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -268,13 +268,36 @@ impl ListSelectionView { impl BottomPaneView for ListSelectionView { fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { + // Some terminals (or configurations) send Control key chords as + // C0 control characters without reporting the CONTROL modifier. + // Handle fallbacks for Ctrl-P/N here so navigation works everywhere. KeyEvent { code: KeyCode::Up, .. - } => self.move_up(), + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^P */ => self.move_up(), KeyEvent { code: KeyCode::Down, .. - } => self.move_down(), + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^N */ => self.move_down(), KeyEvent { code: KeyCode::Backspace, .. From 222a49157077d0010e57e48bf8ec4144c50702c4 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 13:43:04 -0800 Subject: [PATCH 32/94] load models from disk and set a ttl and etag (#7722) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/codex-api/src/endpoint/models.rs | 77 ++++- .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/src/auth.rs | 18 +- codex-rs/core/src/conversation_manager.rs | 3 +- codex-rs/core/src/openai_models/cache.rs | 56 ++++ codex-rs/core/src/openai_models/mod.rs | 1 + .../core/src/openai_models/models_manager.rs | 278 +++++++++++++++--- codex-rs/core/tests/common/responses.rs | 9 +- codex-rs/core/tests/suite/remote_models.rs | 3 +- codex-rs/protocol/src/openai_models.rs | 2 + codex-rs/tui/src/app.rs | 8 +- codex-rs/tui/src/chatwidget.rs | 20 +- codex-rs/tui/src/chatwidget/tests.rs | 8 +- 13 files changed, 414 insertions(+), 70 deletions(-) create mode 100644 codex-rs/core/src/openai_models/cache.rs diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs index 39f7b30c321..5de08432f04 100644 --- a/codex-rs/codex-api/src/endpoint/models.rs +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -8,6 +8,7 @@ use codex_client::RequestTelemetry; use codex_protocol::openai_models::ModelsResponse; use http::HeaderMap; use http::Method; +use http::header::ETAG; use std::sync::Arc; pub struct ModelsClient { @@ -59,12 +60,23 @@ impl ModelsClient { ) .await?; - serde_json::from_slice::(&resp.body).map_err(|e| { - ApiError::Stream(format!( - "failed to decode models response: {e}; body: {}", - String::from_utf8_lossy(&resp.body) - )) - }) + let header_etag = resp + .headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + + let ModelsResponse { models, etag } = serde_json::from_slice::(&resp.body) + .map_err(|e| { + ApiError::Stream(format!( + "failed to decode models response: {e}; body: {}", + String::from_utf8_lossy(&resp.body) + )) + })?; + + let etag = header_etag.unwrap_or(etag); + + Ok(ModelsResponse { models, etag }) } } @@ -86,20 +98,36 @@ mod tests { use std::sync::Mutex; use std::time::Duration; - #[derive(Clone, Default)] + #[derive(Clone)] struct CapturingTransport { last_request: Arc>>, body: Arc, } + impl Default for CapturingTransport { + fn default() -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(ModelsResponse { + models: Vec::new(), + etag: String::new(), + }), + } + } + } + #[async_trait] impl HttpTransport for CapturingTransport { async fn execute(&self, req: Request) -> Result { *self.last_request.lock().unwrap() = Some(req); let body = serde_json::to_vec(&*self.body).unwrap(); + let mut headers = HeaderMap::new(); + if !self.body.etag.is_empty() { + headers.insert(ETAG, self.body.etag.parse().unwrap()); + } Ok(Response { status: StatusCode::OK, - headers: HeaderMap::new(), + headers, body: body.into(), }) } @@ -138,7 +166,10 @@ mod tests { #[tokio::test] async fn appends_client_version_query() { - let response = ModelsResponse { models: Vec::new() }; + let response = ModelsResponse { + models: Vec::new(), + etag: String::new(), + }; let transport = CapturingTransport { last_request: Arc::new(Mutex::new(None)), @@ -191,6 +222,7 @@ mod tests { })) .unwrap(), ], + etag: String::new(), }; let transport = CapturingTransport { @@ -214,4 +246,31 @@ mod tests { assert_eq!(result.models[0].supported_in_api, true); assert_eq!(result.models[0].priority, 1); } + + #[tokio::test] + async fn list_models_includes_etag() { + let response = ModelsResponse { + models: Vec::new(), + etag: "\"abc\"".to_string(), + }; + + let transport = CapturingTransport { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(response), + }; + + let client = ModelsClient::new( + transport, + provider("https://example.com/api/codex"), + DummyAuth, + ); + + let result = client + .list_models("0.1.0", HeaderMap::new()) + .await + .expect("request should succeed"); + + assert_eq!(result.models.len(), 0); + assert_eq!(result.etag, "\"abc\""); + } } diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index fff9c53f7a9..6ef328188f5 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -78,6 +78,7 @@ async fn models_client_hits_models_endpoint() { priority: 1, upgrade: None, }], + etag: String::new(), }; Mock::given(method("GET")) diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 72359ca4cae..57ffa172607 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -32,7 +32,9 @@ use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; use codex_protocol::account::PlanType as AccountPlanType; +use once_cell::sync::Lazy; use serde_json::Value; +use tempfile::TempDir; use thiserror::Error; #[derive(Debug, Clone)] @@ -62,6 +64,8 @@ const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +static TEST_AUTH_TEMP_DIRS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); + #[derive(Debug, Error)] pub enum RefreshTokenError { #[error("{0}")] @@ -1088,11 +1092,19 @@ impl AuthManager { } } + #[cfg(any(test, feature = "test-support"))] + #[expect(clippy::expect_used)] /// Create an AuthManager with a specific CodexAuth, for testing only. pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { let cached = CachedAuth { auth: Some(auth) }; + let temp_dir = tempfile::tempdir().expect("temp codex home"); + let codex_home = temp_dir.path().to_path_buf(); + TEST_AUTH_TEMP_DIRS + .lock() + .expect("lock test codex homes") + .push(temp_dir); Arc::new(Self { - codex_home: PathBuf::new(), + codex_home, inner: RwLock::new(cached), enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, @@ -1104,6 +1116,10 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } + pub fn codex_home(&self) -> &Path { + &self.codex_home + } + /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub fn reload(&self) -> bool { diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index e527507c1c4..b1818849eb4 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -51,6 +51,7 @@ impl ConversationManager { } } + #[cfg(any(test, feature = "test-support"))] /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. pub fn with_auth(auth: CodexAuth) -> Self { @@ -213,7 +214,7 @@ impl ConversationManager { } pub async fn list_models(&self) -> Vec { - self.models_manager.available_models.read().await.clone() + self.models_manager.list_models().await } pub fn get_models_manager(&self) -> Arc { diff --git a/codex-rs/core/src/openai_models/cache.rs b/codex-rs/core/src/openai_models/cache.rs new file mode 100644 index 00000000000..cac16cc8530 --- /dev/null +++ b/codex-rs/core/src/openai_models/cache.rs @@ -0,0 +1,56 @@ +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::openai_models::ModelInfo; +use serde::Deserialize; +use serde::Serialize; +use std::io; +use std::io::ErrorKind; +use std::path::Path; +use std::time::Duration; +use tokio::fs; + +/// Serialized snapshot of models and metadata cached on disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ModelsCache { + pub(crate) fetched_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) etag: Option, + pub(crate) models: Vec, +} + +impl ModelsCache { + /// Returns `true` when the cache entry has not exceeded the configured TTL. + pub(crate) fn is_fresh(&self, ttl: Duration) -> bool { + if ttl.is_zero() { + return false; + } + let Ok(ttl_duration) = chrono::Duration::from_std(ttl) else { + return false; + }; + let age = Utc::now().signed_duration_since(self.fetched_at); + age <= ttl_duration + } +} + +/// Read and deserialize the cache file if it exists. +pub(crate) async fn load_cache(path: &Path) -> io::Result> { + match fs::read(path).await { + Ok(contents) => { + let cache = serde_json::from_slice(&contents) + .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; + Ok(Some(cache)) + } + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + +/// Persist the cache contents to disk, creating parent directories as needed. +pub(crate) async fn save_cache(path: &Path, cache: &ModelsCache) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + let json = serde_json::to_vec_pretty(cache) + .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; + fs::write(path, json).await +} diff --git a/codex-rs/core/src/openai_models/mod.rs b/codex-rs/core/src/openai_models/mod.rs index e7a8beddb13..a77438ebc98 100644 --- a/codex-rs/core/src/openai_models/mod.rs +++ b/codex-rs/core/src/openai_models/mod.rs @@ -1,3 +1,4 @@ +mod cache; pub mod model_family; pub mod model_presets; pub mod models_manager; diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 55c11f4554b..d50844098ee 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -1,11 +1,19 @@ +use chrono::Utc; use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelsResponse; use http::HeaderMap; +use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use tokio::sync::RwLock; +use tokio::sync::TryLockError; +use tracing::error; +use super::cache; +use super::cache::ModelsCache; use crate::api_bridge::auth_provider_from_auth; use crate::api_bridge::map_api_error; use crate::auth::AuthManager; @@ -17,29 +25,41 @@ use crate::openai_models::model_family::ModelFamily; use crate::openai_models::model_family::find_family_for_model; use crate::openai_models::model_presets::builtin_model_presets; +const MODEL_CACHE_FILE: &str = "models_cache.json"; +const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); + +/// Coordinates remote model discovery plus cached metadata on disk. #[derive(Debug)] pub struct ModelsManager { // todo(aibrahim) merge available_models and model family creation into one struct - pub available_models: RwLock>, - pub remote_models: RwLock>, - pub etag: String, - pub auth_manager: Arc, + available_models: RwLock>, + remote_models: RwLock>, + auth_manager: Arc, + etag: RwLock>, + codex_home: PathBuf, + cache_ttl: Duration, } impl ModelsManager { + /// Construct a manager scoped to the provided `AuthManager`. pub fn new(auth_manager: Arc) -> Self { + let codex_home = auth_manager.codex_home().to_path_buf(); Self { available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), remote_models: RwLock::new(Vec::new()), - etag: String::new(), auth_manager, + etag: RwLock::new(None), + codex_home, + cache_ttl: DEFAULT_MODEL_CACHE_TTL, } } - pub async fn refresh_available_models( - &self, - provider: &ModelProviderInfo, - ) -> CoreResult> { + /// Fetch the latest remote models, using the on-disk cache when still fresh. + pub async fn refresh_available_models(&self, provider: &ModelProviderInfo) -> CoreResult<()> { + if self.try_load_cache().await { + return Ok(()); + } + let auth = self.auth_manager.auth(); let api_provider = provider.to_api_provider(auth.as_ref().map(|auth| auth.mode))?; let api_auth = auth_provider_from_auth(auth.clone(), provider).await?; @@ -50,21 +70,30 @@ impl ModelsManager { if client_version == "0.0.0" { client_version = "99.99.99"; } - let response = client + let ModelsResponse { models, etag } = client .list_models(client_version, HeaderMap::new()) .await .map_err(map_api_error)?; - let models = response.models; - *self.remote_models.write().await = models.clone(); - let available_models = self.build_available_models().await; - { - let mut available_models_guard = self.available_models.write().await; - *available_models_guard = available_models; - } - Ok(models) + let etag = (!etag.is_empty()).then_some(etag); + + self.apply_remote_models(models.clone()).await; + *self.etag.write().await = etag.clone(); + self.persist_cache(&models, etag).await; + Ok(()) + } + + pub async fn list_models(&self) -> Vec { + self.available_models.read().await.clone() + } + + pub fn try_list_models(&self) -> Result, TryLockError> { + self.available_models + .try_read() + .map(|models| models.clone()) } + /// Look up the requested model family while applying remote metadata overrides. pub async fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { find_family_for_model(model) .with_config_overrides(config) @@ -72,11 +101,55 @@ impl ModelsManager { } #[cfg(any(test, feature = "test-support"))] + /// Offline helper that builds a `ModelFamily` without consulting remote state. pub fn construct_model_family_offline(model: &str, config: &Config) -> ModelFamily { find_family_for_model(model).with_config_overrides(config) } - async fn build_available_models(&self) -> Vec { + /// Replace the cached remote models and rebuild the derived presets list. + async fn apply_remote_models(&self, models: Vec) { + *self.remote_models.write().await = models; + self.build_available_models().await; + } + + /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. + async fn try_load_cache(&self) -> bool { + let cache_path = self.cache_path(); + let cache = match cache::load_cache(&cache_path).await { + Ok(cache) => cache, + Err(err) => { + error!("failed to load models cache: {err}"); + return false; + } + }; + let cache = match cache { + Some(cache) => cache, + None => return false, + }; + if !cache.is_fresh(self.cache_ttl) { + return false; + } + let models = cache.models.clone(); + *self.etag.write().await = cache.etag.clone(); + self.apply_remote_models(models.clone()).await; + true + } + + /// Serialize the latest fetch to disk for reuse across future processes. + async fn persist_cache(&self, models: &[ModelInfo], etag: Option) { + let cache = ModelsCache { + fetched_at: Utc::now(), + etag, + models: models.to_vec(), + }; + let cache_path = self.cache_path(); + if let Err(err) = cache::save_cache(&cache_path, &cache).await { + error!("failed to write models cache: {err}"); + } + } + + /// Convert remote model metadata into picker-ready presets, marking defaults. + async fn build_available_models(&self) { let mut available_models = self.remote_models.read().await.clone(); available_models.sort_by(|a, b| b.priority.cmp(&a.priority)); let mut model_presets: Vec = available_models @@ -87,22 +160,29 @@ impl ModelsManager { if let Some(default) = model_presets.first_mut() { default.is_default = true; } - model_presets + { + let mut available_models_guard = self.available_models.write().await; + *available_models_guard = model_presets; + } + } + + fn cache_path(&self) -> PathBuf { + self.codex_home.join(MODEL_CACHE_FILE) } } #[cfg(test)] mod tests { + use super::cache::ModelsCache; use super::*; use crate::CodexAuth; + use crate::auth::AuthCredentialsStoreMode; use crate::model_provider_info::WireApi; use codex_protocol::openai_models::ModelsResponse; + use core_test_support::responses::mount_models_once; use serde_json::json; - use wiremock::Mock; + use tempfile::tempdir; use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { serde_json::from_value(json!({ @@ -146,35 +226,28 @@ mod tests { remote_model("priority-low", "Low", 1), remote_model("priority-high", "High", 10), ]; - let response = ModelsResponse { - models: remote_models.clone(), - }; - Mock::given(method("GET")) - .and(path("/models")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/json") - .set_body_json(&response), - ) - .expect(1) - .mount(&server) - .await; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: remote_models.clone(), + etag: String::new(), + }, + ) + .await; let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); - let returned = manager + manager .refresh_available_models(&provider) .await .expect("refresh succeeds"); - - assert_eq!(returned, remote_models); let cached_remote = manager.remote_models.read().await.clone(); assert_eq!(cached_remote, remote_models); - let available = manager.available_models.read().await.clone(); + let available = manager.list_models().await; assert_eq!(available.len(), 2); assert_eq!(available[0].model, "priority-high"); assert!( @@ -183,5 +256,128 @@ mod tests { ); assert_eq!(available[1].model, "priority-low"); assert!(!available[1].is_default); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + } + + #[tokio::test] + async fn refresh_available_models_uses_cache_when_fresh() { + let server = MockServer::start().await; + let remote_models = vec![remote_model("cached", "Cached", 5)]; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: remote_models.clone(), + etag: String::new(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = Arc::new(AuthManager::new( + codex_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + let manager = ModelsManager::new(auth_manager); + let provider = provider_for(server.uri()); + + manager + .refresh_available_models(&provider) + .await + .expect("first refresh succeeds"); + assert_eq!( + *manager.remote_models.read().await, + remote_models, + "remote cache should store fetched models" + ); + + // Second call should read from cache and avoid the network. + manager + .refresh_available_models(&provider) + .await + .expect("cached refresh succeeds"); + assert_eq!( + *manager.remote_models.read().await, + remote_models, + "cache path should not mutate stored models" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "cache hit should avoid a second /models request" + ); + } + + #[tokio::test] + async fn refresh_available_models_refetches_when_cache_stale() { + let server = MockServer::start().await; + let initial_models = vec![remote_model("stale", "Stale", 1)]; + let initial_mock = mount_models_once( + &server, + ModelsResponse { + models: initial_models.clone(), + etag: String::new(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = Arc::new(AuthManager::new( + codex_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + let manager = ModelsManager::new(auth_manager); + let provider = provider_for(server.uri()); + + manager + .refresh_available_models(&provider) + .await + .expect("initial refresh succeeds"); + + // Rewrite cache with an old timestamp so it is treated as stale. + let cache_path = codex_home.path().join(MODEL_CACHE_FILE); + let contents = + std::fs::read_to_string(&cache_path).expect("cache file should exist after refresh"); + let mut cache: ModelsCache = + serde_json::from_str(&contents).expect("cache should deserialize"); + cache.fetched_at = Utc::now() - chrono::Duration::hours(1); + std::fs::write(&cache_path, serde_json::to_string_pretty(&cache).unwrap()) + .expect("cache rewrite succeeds"); + + let updated_models = vec![remote_model("fresh", "Fresh", 9)]; + server.reset().await; + let refreshed_mock = mount_models_once( + &server, + ModelsResponse { + models: updated_models.clone(), + etag: String::new(), + }, + ) + .await; + + manager + .refresh_available_models(&provider) + .await + .expect("second refresh succeeds"); + assert_eq!( + *manager.remote_models.read().await, + updated_models, + "stale cache should trigger refetch" + ); + assert_eq!( + initial_mock.requests().len(), + 1, + "initial refresh should only hit /models once" + ); + assert_eq!( + refreshed_mock.requests().len(), + 1, + "stale cache refresh should fetch /models once" + ); } } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index e42b4ac943b..c67daeda875 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -677,7 +677,14 @@ pub async fn start_mock_server() -> MockServer { .await; // Provide a default `/models` response so tests remain hermetic when the client queries it. - let _ = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; + let _ = mount_models_once( + &server, + ModelsResponse { + models: Vec::new(), + etag: String::new(), + }, + ) + .await; server } diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 4178ed1c2a1..b13188d5d1c 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -73,6 +73,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { &server, ModelsResponse { models: vec![remote_model], + etag: String::new(), }, ) .await; @@ -170,7 +171,7 @@ async fn wait_for_model_available(manager: &Arc, slug: &str) -> M let deadline = Instant::now() + Duration::from_secs(2); loop { if let Some(model) = { - let guard = manager.available_models.read().await; + let guard = manager.list_models().await; guard.iter().find(|model| model.model == slug).cloned() } { return model; diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 0804811a3fa..942303a9027 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -141,6 +141,8 @@ pub struct ModelInfo { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)] pub struct ModelsResponse { pub models: Vec, + #[serde(default)] + pub etag: String, } fn default_visibility() -> ModelVisibility { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 10d11d05352..06fb2a83e14 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -127,7 +127,7 @@ async fn handle_model_migration_prompt_if_needed( auth_mode: Option, models_manager: Arc, ) -> Option { - let available_models = models_manager.available_models.read().await.clone(); + let available_models = models_manager.list_models().await; let upgrade = available_models .iter() .find(|preset| preset.model == config.model) @@ -139,12 +139,12 @@ async fn handle_model_migration_prompt_if_needed( migration_config_key, }) = upgrade { - if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key) { + if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key.as_str()) { return None; } let target_model = target_model.to_string(); - let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key); + let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str()); if !should_show_model_migration_prompt( &config.model, &target_model, @@ -154,7 +154,7 @@ async fn handle_model_migration_prompt_if_needed( return None; } - let prompt_copy = migration_copy_for_config(migration_config_key); + let prompt_copy = migration_copy_for_config(migration_config_key.as_str()); match run_model_migration_prompt(tui, prompt_copy).await { ModelMigrationOutcome::Accepted => { app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 41fe181b33b..2ddab1626af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2053,7 +2053,7 @@ impl ChatWidget { } fn lower_cost_preset(&self) -> Option { - let models = self.models_manager.available_models.try_read().ok()?; + let models = self.models_manager.try_list_models().ok()?; models .iter() .find(|preset| preset.model == NUDGE_MODEL_SLUG) @@ -2162,14 +2162,16 @@ impl ChatWidget { let current_model = self.config.model.clone(); let presets: Vec = // todo(aibrahim): make this async function - if let Ok(models) = self.models_manager.available_models.try_read() { - models.clone() - } else { - self.add_info_message( - "Models are being updated; please try /model again in a moment.".to_string(), - None, - ); - return; + match self.models_manager.try_list_models() { + Ok(models) => models, + Err(_) => { + self.add_info_message( + "Models are being updated; please try /model again in a moment." + .to_string(), + None, + ); + return; + } }; let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 229e075e7ff..cebcd05d5a0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -956,9 +956,11 @@ fn active_blob(chat: &ChatWidget) -> String { } fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { - chat.models_manager - .available_models - .blocking_read() + let models = chat + .models_manager + .try_list_models() + .expect("models lock available"); + models .iter() .find(|&preset| preset.model == model) .cloned() From 0a32acaa2deb8883e9dcbfb80e84957837d39b74 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Mon, 8 Dec 2025 13:56:22 -0800 Subject: [PATCH 33/94] updating app server types to support execpoilcy amendment (#7747) also includes minor refactor merging `ApprovalDecision` with `CommandExecutionRequestAcceptSettings` --- .../app-server-protocol/src/protocol/v2.rs | 42 +++++++++++++------ codex-rs/app-server-test-client/src/main.rs | 6 ++- .../app-server/src/bespoke_event_handling.rs | 38 ++++++++++------- .../app-server/tests/suite/v2/turn_start.rs | 1 - 4 files changed, 57 insertions(+), 30 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 35d70476619..ea70b805b0a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; +use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; @@ -287,6 +288,11 @@ v2_enum_from_core!( #[ts(export_to = "v2/")] pub enum ApprovalDecision { Accept, + /// Approve and remember the approval for the session. + AcceptForSession, + AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment, + }, Decline, Cancel, } @@ -382,6 +388,27 @@ impl From for SandboxCommandAssessment { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "Array", export_to = "v2/")] +pub struct ExecPolicyAmendment { + pub command: Vec, +} + +impl ExecPolicyAmendment { + pub fn into_core(self) -> CoreExecPolicyAmendment { + CoreExecPolicyAmendment::new(self.command) + } +} + +impl From for ExecPolicyAmendment { + fn from(value: CoreExecPolicyAmendment) -> Self { + Self { + command: value.command().to_vec(), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -1468,15 +1495,8 @@ pub struct CommandExecutionRequestApprovalParams { pub reason: Option, /// Optional model-provided risk assessment describing the blocked command. pub risk: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestAcceptSettings { - /// If true, automatically approve this command for the duration of the session. - #[serde(default)] - pub for_session: bool, + /// Optional proposed execpolicy amendment to allow similar commands without prompting. + pub proposed_execpolicy_amendment: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1484,10 +1504,6 @@ pub struct CommandExecutionRequestAcceptSettings { #[ts(export_to = "v2/")] pub struct CommandExecutionRequestApprovalResponse { pub decision: ApprovalDecision, - /// Optional approval settings for when the decision is `accept`. - /// Ignored if the decision is `decline` or `cancel`. - #[serde(default)] - pub accept_settings: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 8c2a38e46c9..92255cecd30 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -21,7 +21,6 @@ use codex_app_server_protocol::ApprovalDecision; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::CommandExecutionRequestAcceptSettings; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -754,6 +753,7 @@ impl CodexClient { item_id, reason, risk, + proposed_execpolicy_amendment, } = params; println!( @@ -765,10 +765,12 @@ impl CodexClient { if let Some(risk) = risk.as_ref() { println!("< risk assessment: {risk:?}"); } + if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() { + println!("< proposed execpolicy amendment: {execpolicy_amendment:?}"); + } let response = CommandExecutionRequestApprovalResponse { decision: ApprovalDecision::Accept, - accept_settings: Some(CommandExecutionRequestAcceptSettings { for_session: false }), }; self.send_server_request_response(request_id, &response)?; println!("< approved commandExecution request for item {item_id}"); diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 94676999b53..2fda7bcf58f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::ContextCompactedNotification; use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::ExecCommandApprovalParams; use codex_app_server_protocol::ExecCommandApprovalResponse; +use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment; use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; @@ -179,7 +180,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, reason, risk, - proposed_execpolicy_amendment: _, + proposed_execpolicy_amendment, parsed_cmd, }) => match api_version { ApiVersion::V1 => { @@ -207,6 +208,8 @@ pub(crate) async fn apply_bespoke_event_handling( .map(V2ParsedCommand::from) .collect::>(); let command_string = shlex_join(&command); + let proposed_execpolicy_amendment_v2 = + proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); let params = CommandExecutionRequestApprovalParams { thread_id: conversation_id.to_string(), @@ -216,6 +219,7 @@ pub(crate) async fn apply_bespoke_event_handling( item_id: item_id.clone(), reason, risk: risk.map(V2SandboxCommandAssessment::from), + proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, }; let rx = outgoing .send_request(ServerRequestPayload::CommandExecutionRequestApproval( @@ -1047,7 +1051,11 @@ async fn on_file_change_request_approval_response( }); let (decision, completion_status) = match response.decision { - ApprovalDecision::Accept => (ReviewDecision::Approved, None), + ApprovalDecision::Accept + | ApprovalDecision::AcceptForSession + | ApprovalDecision::AcceptWithExecpolicyAmendment { .. } => { + (ReviewDecision::Approved, None) + } ApprovalDecision::Decline => { (ReviewDecision::Denied, Some(PatchApplyStatus::Declined)) } @@ -1109,25 +1117,27 @@ async fn on_command_execution_request_approval_response( error!("failed to deserialize CommandExecutionRequestApprovalResponse: {err}"); CommandExecutionRequestApprovalResponse { decision: ApprovalDecision::Decline, - accept_settings: None, } }); - let CommandExecutionRequestApprovalResponse { - decision, - accept_settings, - } = response; + let decision = response.decision; - let (decision, completion_status) = match (decision, accept_settings) { - (ApprovalDecision::Accept, Some(settings)) if settings.for_session => { - (ReviewDecision::ApprovedForSession, None) - } - (ApprovalDecision::Accept, _) => (ReviewDecision::Approved, None), - (ApprovalDecision::Decline, _) => ( + let (decision, completion_status) = match decision { + ApprovalDecision::Accept => (ReviewDecision::Approved, None), + ApprovalDecision::AcceptForSession => (ReviewDecision::ApprovedForSession, None), + ApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => ( + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: execpolicy_amendment.into_core(), + }, + None, + ), + ApprovalDecision::Decline => ( ReviewDecision::Denied, Some(CommandExecutionStatus::Declined), ), - (ApprovalDecision::Cancel, _) => ( + ApprovalDecision::Cancel => ( ReviewDecision::Abort, Some(CommandExecutionStatus::Declined), ), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index e4cd7229474..afc22c70720 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -427,7 +427,6 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { request_id, serde_json::to_value(CommandExecutionRequestApprovalResponse { decision: ApprovalDecision::Decline, - accept_settings: None, })?, ) .await?; From 71c75e648c57404568c8a11a687694d14a44f42e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 14:22:51 -0800 Subject: [PATCH 34/94] Enhance model picker (#7709) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/tui/src/app.rs | 31 ++++-- codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/chatwidget.rs | 159 +++++++++++++++++++++------ codex-rs/tui/src/chatwidget/tests.rs | 2 +- 4 files changed, 153 insertions(+), 44 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 06fb2a83e14..0a09b15e7c8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -700,6 +700,9 @@ impl App { AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } + AppEvent::OpenAllModelsPopup { models } => { + self.chat_widget.open_all_models_popup(models); + } AppEvent::OpenFullAccessConfirmation { preset } => { self.chat_widget.open_full_access_confirmation(preset); } @@ -799,20 +802,17 @@ impl App { .await { Ok(()) => { - let reasoning_label = Self::reasoning_label(effort); + let mut message = format!("Model changed to {model}"); + if let Some(label) = Self::reasoning_label_for(&model, effort) { + message.push(' '); + message.push_str(label); + } if let Some(profile) = profile { - self.chat_widget.add_info_message( - format!( - "Model changed to {model} {reasoning_label} for {profile} profile" - ), - None, - ); - } else { - self.chat_widget.add_info_message( - format!("Model changed to {model} {reasoning_label}"), - None, - ); + message.push_str(" for "); + message.push_str(profile); + message.push_str(" profile"); } + self.chat_widget.add_info_message(message, None); } Err(err) => { tracing::error!( @@ -1012,6 +1012,13 @@ impl App { } } + fn reasoning_label_for( + model: &str, + reasoning_effort: Option, + ) -> Option<&'static str> { + (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) + } + pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { self.chat_widget.token_usage() } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 3a199593bbb..c92dab4b3a8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -74,6 +74,11 @@ pub(crate) enum AppEvent { model: ModelPreset, }, + /// Open the full model picker (non-auto models). + OpenAllModelsPopup { + models: Vec, + }, + /// Open the confirmation prompt before enabling full access mode. OpenFullAccessConfirmation { preset: ApprovalPreset, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2ddab1626af..c8f221de682 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2156,8 +2156,8 @@ impl ChatWidget { }); } - /// Open a popup to choose the model (stage 1). After selecting a model, - /// a second popup is shown to choose the reasoning effort. + /// Open a popup to choose a quick auto model. Selecting "All models" + /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { let current_model = self.config.model.clone(); let presets: Vec = @@ -2174,13 +2174,103 @@ impl ChatWidget { } }; + let current_label = presets + .iter() + .find(|preset| preset.model == current_model) + .map(|preset| preset.display_name.to_string()) + .unwrap_or_else(|| current_model.clone()); + + let (mut auto_presets, other_presets): (Vec, Vec) = presets + .into_iter() + .partition(|preset| Self::is_auto_model(&preset.model)); + + if auto_presets.is_empty() { + self.open_all_models_popup(other_presets); + return; + } + + auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); + + let mut items: Vec = auto_presets + .into_iter() + .map(|preset| { + let description = + (!preset.description.is_empty()).then_some(preset.description.clone()); + let model = preset.model.clone(); + let actions = Self::model_selection_actions( + model.clone(), + Some(preset.default_reasoning_effort), + ); + SelectionItem { + name: preset.display_name, + description, + is_current: model == current_model, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + if !other_presets.is_empty() { + let all_models = other_presets; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAllModelsPopup { + models: all_models.clone(), + }); + })]; + + let is_current = !items.iter().any(|item| item.is_current); + let description = Some(format!( + "Choose a specific model and reasoning level (current: {current_label})" + )); + + items.push(SelectionItem { + name: "All models".to_string(), + description, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Model".to_string()), + subtitle: Some("Pick a quick auto mode or browse all models.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn is_auto_model(model: &str) -> bool { + model.starts_with("codex-auto-") + } + + fn auto_model_order(model: &str) -> usize { + match model { + "codex-auto-fast" => 0, + "codex-auto-balanced" => 1, + "codex-auto-thorough" => 2, + _ => 3, + } + } + + pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { + if presets.is_empty() { + self.add_info_message( + "No additional models are available right now.".to_string(), + None, + ); + return; + } + + let current_model = self.config.model.clone(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { - let description = if preset.description.is_empty() { - None - } else { - Some(preset.description.to_string()) - }; + let description = + (!preset.description.is_empty()).then_some(preset.description.to_string()); let is_current = preset.model == current_model; let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); @@ -2212,6 +2302,36 @@ impl ChatWidget { }); } + fn model_selection_actions( + model_for_action: String, + effort_for_action: Option, + ) -> Vec { + vec![Box::new(move |tx| { + let effort_label = effort_for_action + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "default".to_string()); + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model_for_action.clone()), + effort: Some(effort_for_action), + summary: None, + })); + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: effort_for_action, + }); + tracing::info!( + "Selected model: {}, Selected effort: {}", + model_for_action, + effort_label + ); + })] + } + /// Open a popup to choose the reasoning effort (stage 2) for the given model. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; @@ -2320,30 +2440,7 @@ impl ChatWidget { }; let model_for_action = model_slug.clone(); - let effort_for_action = choice.stored; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - model: Some(model_for_action.clone()), - effort: Some(effort_for_action), - summary: None, - })); - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: effort_for_action, - }); - tracing::info!( - "Selected model: {}, Selected effort: {}", - model_for_action, - effort_for_action - .map(|e| e.to_string()) - .unwrap_or_else(|| "default".to_string()) - ); - })]; + let actions = Self::model_selection_actions(model_for_action, choice.stored); items.push(SelectionItem { name: effort_label, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index cebcd05d5a0..5159d12ce0d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1961,7 +1961,7 @@ fn reasoning_popup_escape_returns_to_model_popup() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); let after_escape = render_bottom_popup(&chat, 80); - assert!(after_escape.contains("Select Model and Effort")); + assert!(after_escape.contains("Select Model")); assert!(!after_escape.contains("Select Reasoning Level")); } From a9f566af7bfb43c126dd6930b5aa8267b19f439c Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 8 Dec 2025 14:33:00 -0800 Subject: [PATCH 35/94] Restore status header after stream recovery (#7660) ## Summary - restore the previous status header when a non-error event arrives after a stream retry - add a regression test to ensure the reconnect banner clears once streaming resumes ## Testing - cargo fmt -- --config imports_granularity=Item - cargo clippy --fix --all-features --tests --allow-dirty -p codex-tui - NO_COLOR=0 cargo test -p codex-tui *(fails: vt100 color assertion tests expect colored cells but the environment returns Default colors even with NO_COLOR cleared and TERM/COLORTERM set)* ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_69337f8c77508329b3ea85134d4a7ac7) --- codex-rs/tui/src/chatwidget.rs | 13 +++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 33 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c8f221de682..1302b2343da 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -377,6 +377,14 @@ impl ChatWidget { self.bottom_pane.update_status_header(header); } + fn restore_retry_status_header_if_present(&mut self) { + if let Some(header) = self.retry_status_header.take() + && self.current_status_header != header + { + self.set_status_header(header); + } + } + // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane @@ -1771,6 +1779,11 @@ impl ChatWidget { /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id /// that must not be used to correlate follow-up actions. fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { + let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); + if !is_stream_error { + self.restore_retry_status_header_if_present(); + } + match msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5159d12ce0d..126c91f9d81 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2905,6 +2905,39 @@ fn warning_event_adds_warning_history_cell() { ); } +#[test] +fn stream_recovery_restores_previous_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + chat.handle_codex_event(Event { + id: "task".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "retry".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: "Reconnecting... 1/5".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }); + drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "delta".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "hello".to_string(), + }), + }); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Working"); + assert!(chat.retry_status_header.is_none()); +} + #[test] fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); From cb45139244d57d6af64084df33da76a0fbc3ab99 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 14:52:39 -0800 Subject: [PATCH 36/94] Add formatting client version to the `x.x.x` style. (#7711) To avoid regression with special builds like alphas --- .../core/src/openai_models/models_manager.rs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index d50844098ee..9ebf0112ad2 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -66,12 +66,9 @@ impl ModelsManager { let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); - let mut client_version = env!("CARGO_PKG_VERSION"); - if client_version == "0.0.0" { - client_version = "99.99.99"; - } + let client_version = format_client_version_to_whole(); let ModelsResponse { models, etag } = client - .list_models(client_version, HeaderMap::new()) + .list_models(&client_version, HeaderMap::new()) .await .map_err(map_api_error)?; @@ -171,6 +168,28 @@ impl ModelsManager { } } +/// Convert a client version string to a whole version string (e.g. "1.2.3-alpha.4" -> "1.2.3") +fn format_client_version_to_whole() -> String { + format_client_version_from_parts( + env!("CARGO_PKG_VERSION_MAJOR"), + env!("CARGO_PKG_VERSION_MINOR"), + env!("CARGO_PKG_VERSION_PATCH"), + ) +} + +fn format_client_version_from_parts(major: &str, minor: &str, patch: &str) -> String { + const DEV_VERSION: &str = "0.0.0"; + const FALLBACK_VERSION: &str = "99.99.99"; + + let normalized = format!("{major}.{minor}.{patch}"); + + if normalized == DEV_VERSION { + FALLBACK_VERSION.to_string() + } else { + normalized + } +} + #[cfg(test)] mod tests { use super::cache::ModelsCache; From badda736c6a086a3b6a5766ea471cd9a2616f235 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 8 Dec 2025 15:12:01 -0800 Subject: [PATCH 37/94] feat: windows codesign with Azure trusted signing (#7675) ### Summary Set up codesign for windows dist with [Azure trusted signing](https://azure.microsoft.com/en-us/products/trusted-signing) and [its github action integration](https://github.com/Azure/trusted-signing-action). --- .github/actions/windows-code-sign/action.yml | 54 ++++++++++++++++++++ .github/workflows/rust-release.yml | 12 +++++ 2 files changed, 66 insertions(+) create mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml new file mode 100644 index 00000000000..17a4fbf9995 --- /dev/null +++ b/.github/actions/windows-code-sign/action.yml @@ -0,0 +1,54 @@ +name: windows-code-sign +description: Sign Windows binaries with Azure Trusted Signing. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + client-id: + description: Azure Trusted Signing client ID. + required: true + tenant-id: + description: Azure tenant ID for Trusted Signing. + required: true + subscription-id: + description: Azure subscription ID for Trusted Signing. + required: true + endpoint: + description: Azure Trusted Signing endpoint. + required: true + account-name: + description: Azure Trusted Signing account name. + required: true + certificate-profile-name: + description: Certificate profile name for signing. + required: true + +runs: + using: composite + steps: + - name: Azure login for Trusted Signing (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Sign Windows binaries with Azure Trusted Signing + uses: azure/trusted-signing-action@v0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + files: | + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c3e9eeef9a3..b90f0027fa3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,6 +110,18 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ contains(matrix.target, 'windows') }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From ac5fa6baf8898fba30d1f7ad6bea2da986e3fc93 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 8 Dec 2025 15:23:02 -0800 Subject: [PATCH 38/94] Do not emit start/end events for write stdin (#7561) --- .../core/src/tools/handlers/unified_exec.rs | 1 - codex-rs/core/src/unified_exec/mod.rs | 2 - .../core/src/unified_exec/session_manager.rs | 51 +------ codex-rs/core/tests/suite/unified_exec.rs | 136 +++--------------- 4 files changed, 19 insertions(+), 171 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index f2500a413ba..66cf624a6c2 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -215,7 +215,6 @@ impl ToolHandler for UnifiedExecHandler { })?; manager .write_stdin(WriteStdinRequest { - call_id: &call_id, process_id: &args.session_id.to_string(), input: &args.chars, yield_time_ms: args.yield_time_ms, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 34b62df342c..02a0f9ead7f 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -80,7 +80,6 @@ pub(crate) struct ExecCommandRequest { #[derive(Debug)] pub(crate) struct WriteStdinRequest<'a> { - pub call_id: &'a str, pub process_id: &'a str, pub input: &'a str, pub yield_time_ms: u64, @@ -216,7 +215,6 @@ mod tests { .services .unified_exec_manager .write_stdin(WriteStdinRequest { - call_id: "write-stdin", process_id, input, yield_time_ms, diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 88d65ca1424..af706b4b238 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -24,7 +24,6 @@ use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; -use crate::tools::events::ToolEventFailure; use crate::tools::events::ToolEventStage; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; @@ -77,7 +76,6 @@ struct PreparedSessionHandles { session_ref: Arc, turn_ref: Arc, command: Vec, - cwd: PathBuf, process_id: String, } @@ -234,41 +232,12 @@ impl UnifiedExecSessionManager { session_ref, turn_ref, command: session_command, - cwd: session_cwd, process_id, + .. } = self.prepare_session_handles(process_id.as_str()).await?; - let interaction_emitter = ToolEmitter::unified_exec( - &session_command, - session_cwd.clone(), - ExecCommandSource::UnifiedExecInteraction, - (!request.input.is_empty()).then(|| request.input.to_string()), - Some(process_id.clone()), - ); - let make_event_ctx = || { - ToolEventCtx::new( - session_ref.as_ref(), - turn_ref.as_ref(), - request.call_id, - None, - ) - }; - interaction_emitter - .emit(make_event_ctx(), ToolEventStage::Begin) - .await; - if !request.input.is_empty() { - if let Err(err) = Self::send_input(&writer_tx, request.input.as_bytes()).await { - interaction_emitter - .emit( - make_event_ctx(), - ToolEventStage::Failure(ToolEventFailure::Message(format!( - "write_stdin failed: {err:?}" - ))), - ) - .await; - return Err(err); - } + Self::send_input(&writer_tx, request.input.as_bytes()).await?; tokio::time::sleep(Duration::from_millis(100)).await; } @@ -319,21 +288,6 @@ impl UnifiedExecSessionManager { session_command: Some(session_command.clone()), }; - let interaction_output = ExecToolCallOutput { - exit_code: response.exit_code.unwrap_or(0), - stdout: StreamOutput::new(response.output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(response.output.clone()), - duration: response.wall_time, - timed_out: false, - }; - interaction_emitter - .emit( - make_event_ctx(), - ToolEventStage::Success(interaction_output), - ) - .await; - if response.process_id.is_some() { Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await; } @@ -400,7 +354,6 @@ impl UnifiedExecSessionManager { session_ref: Arc::clone(&entry.session_ref), turn_ref: Arc::clone(&entry.turn_ref), command: entry.command.clone(), - cwd: entry.cwd.clone(), process_id: entry.process_id.clone(), }) } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 33e469fc1ad..6a62e35dfbd 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -765,104 +765,7 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_sandbox!(Ok(())); - skip_if_windows!(Ok(())); - - let server = start_mock_server().await; - - let mut builder = test_codex().with_config(|config| { - config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); - }); - let TestCodex { - codex, - cwd, - session_configured, - .. - } = builder.build(&server).await?; - - let open_call_id = "uexec-open-for-begin"; - let open_args = json!({ - "shell": "bash".to_string(), - "cmd": "bash -i".to_string(), - "yield_time_ms": 200, - }); - - let stdin_call_id = "uexec-stdin-begin"; - let stdin_args = json!({ - "chars": "echo hello", - "session_id": 1000, - "yield_time_ms": 400, - }); - - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - open_call_id, - "exec_command", - &serde_json::to_string(&open_args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_function_call( - stdin_call_id, - "write_stdin", - &serde_json::to_string(&stdin_args)?, - ), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-3"), - ]), - ]; - mount_sse_sequence(&server, responses).await; - - let session_model = session_configured.model.clone(); - - codex - .submit(Op::UserTurn { - items: vec![UserInput::Text { - text: "begin events for stdin".into(), - }], - final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, - model: session_model, - effort: None, - summary: ReasoningSummary::Auto, - }) - .await?; - - let begin_event = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandBegin(ev) if ev.call_id == stdin_call_id => Some(ev.clone()), - _ => None, - }) - .await; - - assert_command(&begin_event.command, "-lc", "bash -i"); - assert_eq!( - begin_event.interaction_input, - Some("echo hello".to_string()) - ); - assert_eq!( - begin_event.source, - ExecCommandSource::UnifiedExecInteraction - ); - - wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> { +async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); skip_if_windows!(Ok(())); @@ -883,8 +786,8 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> let open_call_id = "uexec-open-session"; let open_args = json!({ "shell": "bash".to_string(), - "cmd": "bash -i".to_string(), - "yield_time_ms": 250, + "cmd": "sleep 0.1".to_string(), + "yield_time_ms": 10, }); let poll_call_id = "uexec-poll-empty"; @@ -939,10 +842,12 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> .await?; let mut begin_events = Vec::new(); + let mut end_events = Vec::new(); loop { let event_msg = wait_for_event(&codex, |_| true).await; match event_msg { EventMsg::ExecCommandBegin(event) => begin_events.push(event), + EventMsg::ExecCommandEnd(event) => end_events.push(event), EventMsg::TaskComplete(_) => break, _ => {} } @@ -950,16 +855,19 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> assert_eq!( begin_events.len(), - 2, - "expected begin events for the startup command and the write_stdin call" + 1, + "expected begin events for the startup command" ); - let open_event = begin_events - .iter() - .find(|ev| ev.call_id == open_call_id) - .expect("missing exec_command begin"); + assert_eq!( + end_events.len(), + 1, + "expected end event for the write_stdin call" + ); + + let open_event = &begin_events[0]; - assert_command(&open_event.command, "-lc", "bash -i"); + assert_command(&open_event.command, "-lc", "sleep 0.1"); assert!( open_event.interaction_input.is_none(), @@ -967,18 +875,8 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> ); assert_eq!(open_event.source, ExecCommandSource::UnifiedExecStartup); - let poll_event = begin_events - .iter() - .find(|ev| ev.call_id == poll_call_id) - .expect("missing write_stdin begin"); - - assert_command(&poll_event.command, "-lc", "bash -i"); - - assert!( - poll_event.interaction_input.is_none(), - "poll begin events should omit interaction input" - ); - assert_eq!(poll_event.source, ExecCommandSource::UnifiedExecInteraction); + let end_event = &end_events[0]; + assert_eq!(end_event.call_id, open_call_id); Ok(()) } From 382f047a1064742767f84e9c551ea3c6df59d6dd Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 15:29:37 -0800 Subject: [PATCH 39/94] Remove legacy `ModelInfo` and merge it with `ModelFamily` (#7748) This is a step towards removing the need to know `model` when constructing config. We firstly don't need to know `model_info` and just respect if the user has already set it. Next step, we don't need to know `model` unless the user explicitly set it in `config.toml` --- codex-rs/core/src/client.rs | 13 +- codex-rs/core/src/codex.rs | 13 +- codex-rs/core/src/config/mod.rs | 35 ++-- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/openai_model_info.rs | 83 -------- .../core/src/openai_models/model_family.rs | 58 +++++- codex-rs/tui/src/chatwidget.rs | 3 +- codex-rs/tui/src/chatwidget/tests.rs | 183 +++++++++--------- codex-rs/tui/src/status/card.rs | 6 +- codex-rs/tui/src/status/tests.rs | 35 ++++ 10 files changed, 205 insertions(+), 225 deletions(-) delete mode 100644 codex-rs/core/src/openai_model_info.rs diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 4c3cf737b23..d4a714cdd52 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -48,7 +48,6 @@ use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; -use crate::openai_model_info::get_model_info; use crate::openai_models::model_family::ModelFamily; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; @@ -95,19 +94,11 @@ impl ModelClient { pub fn get_model_context_window(&self) -> Option { let model_family = self.get_model_family(); let effective_context_window_percent = model_family.effective_context_window_percent; - self.config - .model_context_window - .or_else(|| get_model_info(&model_family).map(|info| info.context_window)) + model_family + .context_window .map(|w| w.saturating_mul(effective_context_window_percent) / 100) } - pub fn get_auto_compact_token_limit(&self) -> Option { - let model_family = self.get_model_family(); - self.config.model_auto_compact_token_limit.or_else(|| { - get_model_info(&model_family).and_then(|info| info.auto_compact_token_limit) - }) - } - pub fn config(&self) -> Arc { Arc::clone(&self.config) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c33904e2fde..74ef11c3238 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -80,7 +80,6 @@ use crate::exec::StreamOutput; use crate::exec_policy::ExecPolicyUpdateError; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; -use crate::openai_model_info::get_model_info; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageContentDeltaEvent; use crate::protocol::AgentReasoningSectionBreakEvent; @@ -415,15 +414,11 @@ impl Session { otel_event_manager: &OtelEventManager, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, - mut per_turn_config: Config, + per_turn_config: Config, model_family: ModelFamily, conversation_id: ConversationId, sub_id: String, ) -> TurnContext { - if let Some(model_info) = get_model_info(&model_family) { - per_turn_config.model_context_window = Some(model_info.context_window); - } - let otel_event_manager = otel_event_manager.clone().with_model( session_configuration.model.as_str(), model_family.slug.as_str(), @@ -1955,9 +1950,6 @@ async fn spawn_review_thread( per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; per_turn_config.features = review_features.clone(); - if let Some(model_info) = get_model_info(&model_family) { - per_turn_config.model_context_window = Some(model_info.context_window); - } let otel_event_manager = parent_turn_context .client @@ -2097,7 +2089,8 @@ pub(crate) async fn run_task( } = turn_output; let limit = turn_context .client - .get_auto_compact_token_limit() + .get_model_family() + .auto_compact_token_limit() .unwrap_or(i64::MAX); let total_usage_tokens = sess.get_total_token_usage().await; let token_limit_reached = total_usage_tokens >= limit; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a1cc46cf230..df7637a3012 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -26,8 +26,6 @@ use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; use crate::model_provider_info::built_in_model_providers; -use crate::openai_model_info::get_model_info; -use crate::openai_models::model_family::find_family_for_model; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; @@ -1106,23 +1104,12 @@ impl Config { let forced_login_method = cfg.forced_login_method; + // todo(aibrahim): make model optional let model = model .or(config_profile.model) .or(cfg.model) .unwrap_or_else(default_model); - let model_family = find_family_for_model(&model); - - let openai_model_info = get_model_info(&model_family); - let model_context_window = cfg - .model_context_window - .or_else(|| openai_model_info.as_ref().map(|info| info.context_window)); - let model_auto_compact_token_limit = cfg.model_auto_compact_token_limit.or_else(|| { - openai_model_info - .as_ref() - .and_then(|info| info.auto_compact_token_limit) - }); - let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { @@ -1168,8 +1155,8 @@ impl Config { let config = Self { model, review_model, - model_context_window, - model_auto_compact_token_limit, + model_context_window: cfg.model_context_window, + model_auto_compact_token_limit: cfg.model_auto_compact_token_limit, model_provider_id, model_provider, cwd: resolved_cwd, @@ -2950,8 +2937,8 @@ model_verbosity = "high" Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(200_000), - model_auto_compact_token_limit: Some(180_000), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, @@ -3025,8 +3012,8 @@ model_verbosity = "high" let expected_gpt3_profile_config = Config { model: "gpt-3.5-turbo".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(16_385), - model_auto_compact_token_limit: Some(14_746), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai-chat-completions".to_string(), model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: AskForApproval::UnlessTrusted, @@ -3115,8 +3102,8 @@ model_verbosity = "high" let expected_zdr_profile_config = Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(200_000), - model_auto_compact_token_limit: Some(180_000), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, @@ -3191,8 +3178,8 @@ model_verbosity = "high" let expected_gpt5_profile_config = Config { model: "gpt-5.1".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(272_000), - model_auto_compact_token_limit: Some(244_800), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 721c6bb43ca..59dac84d26a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -67,7 +67,6 @@ pub use conversation_manager::NewConversation; pub use auth::AuthManager; pub use auth::CodexAuth; pub mod default_client; -mod openai_model_info; pub mod project_doc; mod rollout; pub(crate) mod safety; diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs deleted file mode 100644 index 4ee7d7187a9..00000000000 --- a/codex-rs/core/src/openai_model_info.rs +++ /dev/null @@ -1,83 +0,0 @@ -use crate::openai_models::model_family::ModelFamily; - -// Shared constants for commonly used window/token sizes. -pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; - -/// Metadata about a model, particularly OpenAI models. -/// We may want to consider including details like the pricing for -/// input tokens, output tokens, etc., though users will need to be able to -/// override this in config.toml, as this information can get out of date. -/// Though this would help present more accurate pricing information in the UI. -#[derive(Debug)] -pub(crate) struct ModelInfo { - /// Size of the context window in tokens. This is the maximum size of the input context. - pub(crate) context_window: i64, - - /// Token threshold where we should automatically compact conversation history. This considers - /// input tokens + output tokens of this turn. - pub(crate) auto_compact_token_limit: Option, -} - -impl ModelInfo { - const fn new(context_window: i64) -> Self { - Self { - context_window, - auto_compact_token_limit: Some(Self::default_auto_compact_limit(context_window)), - } - } - - const fn default_auto_compact_limit(context_window: i64) -> i64 { - (context_window * 9) / 10 - } -} - -pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { - let slug = model_family.slug.as_str(); - match slug { - // OSS models have a 128k shared token pool. - // Arbitrarily splitting it: 3/4 input context, 1/4 output. - // https://openai.com/index/gpt-oss-model-card/ - "gpt-oss-20b" => Some(ModelInfo::new(96_000)), - "gpt-oss-120b" => Some(ModelInfo::new(96_000)), - // https://platform.openai.com/docs/models/o3 - "o3" => Some(ModelInfo::new(200_000)), - - // https://platform.openai.com/docs/models/o4-mini - "o4-mini" => Some(ModelInfo::new(200_000)), - - // https://platform.openai.com/docs/models/codex-mini-latest - "codex-mini-latest" => Some(ModelInfo::new(200_000)), - - // As of Jun 25, 2025, gpt-4.1 defaults to gpt-4.1-2025-04-14. - // https://platform.openai.com/docs/models/gpt-4.1 - "gpt-4.1" | "gpt-4.1-2025-04-14" => Some(ModelInfo::new(1_047_576)), - - // As of Jun 25, 2025, gpt-4o defaults to gpt-4o-2024-08-06. - // https://platform.openai.com/docs/models/gpt-4o - "gpt-4o" | "gpt-4o-2024-08-06" => Some(ModelInfo::new(128_000)), - - // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-05-13 - "gpt-4o-2024-05-13" => Some(ModelInfo::new(128_000)), - - // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-11-20 - "gpt-4o-2024-11-20" => Some(ModelInfo::new(128_000)), - - // https://platform.openai.com/docs/models/gpt-3.5-turbo - "gpt-3.5-turbo" => Some(ModelInfo::new(16_385)), - - _ if slug.starts_with("gpt-5-codex") - || slug.starts_with("gpt-5.1-codex") - || slug.starts_with("gpt-5.1-codex-max") => - { - Some(ModelInfo::new(CONTEXT_WINDOW_272K)) - } - - _ if slug.starts_with("gpt-5") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - - _ if slug.starts_with("codex-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - - _ if slug.starts_with("exp-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - - _ => None, - } -} diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 507e1a48d92..6665165ee55 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -15,6 +15,7 @@ const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md"); const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt_5_codex_prompt.md"); const GPT_5_1_INSTRUCTIONS: &str = include_str!("../../gpt_5_1_prompt.md"); const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-max_prompt.md"); +pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; /// A model family is a group of models that share certain characteristics. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -23,14 +24,20 @@ pub struct ModelFamily { /// "gpt-4.1-2025-04-14". pub slug: String, - /// The model family name, e.g. "gpt-4.1". Note this should able to be used - /// with [`crate::openai_model_info::get_model_info`]. + /// The model family name, e.g. "gpt-4.1". This string is used when deriving + /// default metadata for the family, such as context windows. pub family: String, /// True if the model needs additional instructions on how to use the /// "virtual" `apply_patch` CLI. pub needs_special_apply_patch_instructions: bool, + /// Maximum supported context window, if known. + pub context_window: Option, + + /// Token threshold for automatic compaction if config does not override it. + auto_compact_token_limit: Option, + // Whether the `reasoning` field can be set when making a request to this // model family. Note it has `effort` and `summary` subfields (though // `summary` is optional). @@ -82,6 +89,12 @@ impl ModelFamily { if let Some(reasoning_summary_format) = config.model_reasoning_summary_format.as_ref() { self.reasoning_summary_format = reasoning_summary_format.clone(); } + if let Some(context_window) = config.model_context_window { + self.context_window = Some(context_window); + } + if let Some(auto_compact_token_limit) = config.model_auto_compact_token_limit { + self.auto_compact_token_limit = Some(auto_compact_token_limit); + } self } pub fn with_remote_overrides(mut self, remote_models: Vec) -> Self { @@ -93,6 +106,15 @@ impl ModelFamily { } self } + + pub fn auto_compact_token_limit(&self) -> Option { + self.auto_compact_token_limit + .or(self.context_window.map(Self::default_auto_compact_limit)) + } + + const fn default_auto_compact_limit(context_window: i64) -> i64 { + (context_window * 9) / 10 + } } macro_rules! model_family { @@ -105,6 +127,8 @@ macro_rules! model_family { slug: $slug.to_string(), family: $family.to_string(), needs_special_apply_patch_instructions: false, + context_window: Some(CONTEXT_WINDOW_272K), + auto_compact_token_limit: None, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, supports_parallel_tool_calls: false, @@ -136,12 +160,14 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { slug, "o3", supports_reasoning_summaries: true, needs_special_apply_patch_instructions: true, + context_window: Some(200_000), ) } else if slug.starts_with("o4-mini") { model_family!( slug, "o4-mini", supports_reasoning_summaries: true, needs_special_apply_patch_instructions: true, + context_window: Some(200_000), ) } else if slug.starts_with("codex-mini-latest") { model_family!( @@ -149,18 +175,32 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { supports_reasoning_summaries: true, needs_special_apply_patch_instructions: true, shell_type: ConfigShellToolType::Local, + context_window: Some(200_000), ) } else if slug.starts_with("gpt-4.1") { model_family!( slug, "gpt-4.1", needs_special_apply_patch_instructions: true, + context_window: Some(1_047_576), ) } else if slug.starts_with("gpt-oss") || slug.starts_with("openai/gpt-oss") { - model_family!(slug, "gpt-oss", apply_patch_tool_type: Some(ApplyPatchToolType::Function)) + model_family!( + slug, "gpt-oss", + apply_patch_tool_type: Some(ApplyPatchToolType::Function), + context_window: Some(96_000), + ) } else if slug.starts_with("gpt-4o") { - model_family!(slug, "gpt-4o", needs_special_apply_patch_instructions: true) + model_family!( + slug, "gpt-4o", + needs_special_apply_patch_instructions: true, + context_window: Some(128_000), + ) } else if slug.starts_with("gpt-3.5") { - model_family!(slug, "gpt-3.5", needs_special_apply_patch_instructions: true) + model_family!( + slug, "gpt-3.5", + needs_special_apply_patch_instructions: true, + context_window: Some(16_385), + ) } else if slug.starts_with("test-gpt-5") { model_family!( slug, slug, @@ -196,6 +236,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { supports_parallel_tool_calls: true, support_verbosity: true, truncation_policy: TruncationPolicy::Tokens(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("exp-") { model_family!( @@ -209,6 +250,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Bytes(10_000), shell_type: ConfigShellToolType::UnifiedExec, supports_parallel_tool_calls: true, + context_window: Some(CONTEXT_WINDOW_272K), ) // Production models. @@ -223,6 +265,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { supports_parallel_tool_calls: true, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("gpt-5-codex") || slug.starts_with("gpt-5.1-codex") @@ -238,6 +281,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { supports_parallel_tool_calls: true, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("gpt-5.1") { model_family!( @@ -251,6 +295,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Bytes(10_000), shell_type: ConfigShellToolType::ShellCommand, supports_parallel_tool_calls: true, + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("gpt-5") { model_family!( @@ -260,6 +305,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { shell_type: ConfigShellToolType::Default, support_verbosity: true, truncation_policy: TruncationPolicy::Bytes(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else { derive_default_model_family(slug) @@ -271,6 +317,8 @@ fn derive_default_model_family(model: &str) -> ModelFamily { slug: model.to_string(), family: model.to_string(), needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, supports_parallel_tool_calls: false, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1302b2343da..a0b42ddbe4d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -550,7 +550,7 @@ impl ChatWidget { fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { info.model_context_window - .or(self.config.model_context_window) + .or(self.model_family.context_window) .map(|window| { info.last_token_usage .percent_of_context_window_remaining(window) @@ -2024,6 +2024,7 @@ impl ChatWidget { self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), + &self.model_family, total_usage, context_usage, &self.conversation_id, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 126c91f9d81..0135abff733 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -98,7 +98,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { #[test] fn resumed_initial_messages_render_history() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); let conversation_id = ConversationId::new(); let rollout_file = NamedTempFile::new().unwrap(); @@ -154,7 +154,7 @@ 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(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "review-start".into(), @@ -175,7 +175,7 @@ fn entered_review_mode_uses_request_hint() { /// 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(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "review-start".into(), @@ -195,7 +195,7 @@ fn entered_review_mode_defaults_to_current_changes_banner() { /// 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 (mut chat, mut rx, _ops) = make_chatwidget_manual(None); let review = ReviewOutputEvent { findings: vec![ReviewFinding { @@ -229,7 +229,7 @@ fn exited_review_mode_emits_results_and_finishes() { /// Exiting review restores the pre-review context window indicator. #[test] fn review_restores_context_window_indicator() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); let context_window = 13_000; let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline. @@ -278,7 +278,7 @@ fn review_restores_context_window_indicator() { /// Receiving a TokenCount event without usage clears the context indicator. #[test] fn token_count_none_resets_context_indicator() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(); + let (mut chat, _rx, _ops) = make_chatwidget_manual(None); let context_window = 13_000; let pre_compact_tokens = 12_700; @@ -304,7 +304,7 @@ fn token_count_none_resets_context_indicator() { #[test] fn context_indicator_shows_used_tokens_when_window_unknown() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(); + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")); chat.config.model_context_window = None; let auto_compact_limit = 200_000; @@ -371,7 +371,9 @@ async fn helpers_are_available_and_do_not_panic() { } // --- Helpers for tests that need direct construction and event draining --- -fn make_chatwidget_manual() -> ( +fn make_chatwidget_manual( + model_override: Option<&str>, +) -> ( ChatWidget, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, @@ -379,7 +381,10 @@ fn make_chatwidget_manual() -> ( let (tx_raw, rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); - let cfg = test_config(); + let mut cfg = test_config(); + if let Some(model) = model_override { + cfg.model = model.to_string(); + } let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), @@ -447,7 +452,7 @@ pub(crate) fn make_chatwidget_manual_with_sender() -> ( tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { - let (widget, rx, op_rx) = make_chatwidget_manual(); + let (widget, rx, op_rx) = make_chatwidget_manual(None); let app_event_tx = widget.app_event_tx.clone(); (widget, app_event_tx, rx, op_rx) } @@ -543,7 +548,7 @@ fn test_rate_limit_warnings_monthly() { #[test] fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { primary: None, @@ -592,7 +597,7 @@ fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { #[test] fn rate_limit_snapshot_updates_and_retains_plan_type() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { primary: Some(RateLimitWindow { @@ -645,7 +650,7 @@ fn rate_limit_snapshot_updates_and_retains_plan_type() { #[test] fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { - let (mut chat, _, _) = make_chatwidget_manual(); + let (mut chat, _, _) = make_chatwidget_manual(None); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.config.model = NUDGE_MODEL_SLUG.to_string(); @@ -661,7 +666,7 @@ fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { #[test] fn rate_limit_switch_prompt_shows_once_per_session() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(); + let (mut chat, _, _) = make_chatwidget_manual(None); chat.config.model = "gpt-5".to_string(); chat.auth_manager = AuthManager::from_auth_for_testing(auth); @@ -686,7 +691,7 @@ fn rate_limit_switch_prompt_shows_once_per_session() { #[test] fn rate_limit_switch_prompt_respects_hidden_notice() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(); + let (mut chat, _, _) = make_chatwidget_manual(None); chat.config.model = "gpt-5".to_string(); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.config.notices.hide_rate_limit_model_nudge = Some(true); @@ -702,7 +707,7 @@ fn rate_limit_switch_prompt_respects_hidden_notice() { #[test] fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(); + let (mut chat, _, _) = make_chatwidget_manual(None); chat.config.model = "gpt-5".to_string(); chat.auth_manager = AuthManager::from_auth_for_testing(auth); @@ -723,7 +728,7 @@ fn rate_limit_switch_prompt_defers_until_task_complete() { #[test] fn rate_limit_switch_prompt_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.config.model = "gpt-5".to_string(); @@ -739,7 +744,7 @@ fn rate_limit_switch_prompt_popup_snapshot() { #[test] fn exec_approval_emits_proposed_command_and_decision_history() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Trigger an exec approval request with a short, single-line command let ev = ExecApprovalRequestEvent { @@ -784,7 +789,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { #[test] fn exec_approval_decision_truncates_multiline_and_long_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Multiline command: modal should show full command, history records decision only let ev_multi = ExecApprovalRequestEvent { @@ -969,7 +974,7 @@ fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { #[test] fn empty_enter_during_task_does_not_queue() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Simulate running task so submissions would normally be queued. chat.bottom_pane.set_task_running(true); @@ -983,7 +988,7 @@ fn empty_enter_during_task_does_not_queue() { #[test] fn alt_up_edits_most_recent_queued_message() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(true); @@ -1016,7 +1021,7 @@ fn alt_up_edits_most_recent_queued_message() { /// is queued repeatedly. #[test] fn enqueueing_history_prompt_multiple_times_is_stable() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Submit an initial prompt to seed history. chat.bottom_pane.set_composer_text("repeat me".to_string()); @@ -1042,7 +1047,7 @@ fn enqueueing_history_prompt_multiple_times_is_stable() { #[test] fn streaming_final_answer_keeps_task_running_state() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); chat.on_task_started(); chat.on_agent_message_delta("Final answer line\n".to_string()); @@ -1072,7 +1077,7 @@ fn streaming_final_answer_keeps_task_running_state() { #[test] fn ctrl_c_shutdown_ignores_caps_lock() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); @@ -1084,7 +1089,7 @@ fn ctrl_c_shutdown_ignores_caps_lock() { #[test] fn ctrl_c_cleared_prompt_is_recoverable_via_history() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); chat.bottom_pane.insert_str("draft message "); chat.bottom_pane @@ -1118,7 +1123,7 @@ fn ctrl_c_cleared_prompt_is_recoverable_via_history() { #[test] fn exec_history_cell_shows_working_then_completed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin command let begin = begin_exec(&mut chat, "call-1", "echo done"); @@ -1148,7 +1153,7 @@ fn exec_history_cell_shows_working_then_completed() { #[test] fn exec_history_cell_shows_working_then_failed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin command let begin = begin_exec(&mut chat, "call-2", "false"); @@ -1172,7 +1177,7 @@ fn exec_history_cell_shows_working_then_failed() { #[test] fn exec_history_shows_unified_exec_startup_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let begin = begin_exec_with_source( &mut chat, @@ -1200,7 +1205,7 @@ fn exec_history_shows_unified_exec_startup_commands() { /// 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(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Open the preset selection popup chat.open_review_popup(); @@ -1225,7 +1230,7 @@ fn review_popup_custom_prompt_action_sends_event() { #[test] fn slash_init_skips_when_project_doc_exists() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); let tempdir = tempdir().unwrap(); let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); std::fs::write(&existing_path, "existing instructions").unwrap(); @@ -1257,7 +1262,7 @@ fn slash_init_skips_when_project_doc_exists() { #[test] fn slash_quit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Quit); @@ -1266,7 +1271,7 @@ fn slash_quit_requests_exit() { #[test] fn slash_exit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Exit); @@ -1275,7 +1280,7 @@ fn slash_exit_requests_exit() { #[test] fn slash_resume_opens_picker() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Resume); @@ -1284,7 +1289,7 @@ fn slash_resume_opens_picker() { #[test] fn slash_undo_sends_op() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Undo); @@ -1296,7 +1301,7 @@ fn slash_undo_sends_op() { #[test] fn slash_rollout_displays_current_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl"); chat.current_rollout_path = Some(rollout_path.clone()); @@ -1313,7 +1318,7 @@ fn slash_rollout_displays_current_path() { #[test] fn slash_rollout_handles_missing_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Rollout); @@ -1332,7 +1337,7 @@ fn slash_rollout_handles_missing_path() { #[test] fn undo_success_events_render_info_messages() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "turn-1".to_string(), @@ -1369,7 +1374,7 @@ fn undo_success_events_render_info_messages() { #[test] fn undo_failure_events_render_error_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "turn-2".to_string(), @@ -1404,7 +1409,7 @@ fn undo_failure_events_render_error_message() { #[test] fn undo_started_hides_interrupt_hint() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "turn-hint".to_string(), @@ -1424,7 +1429,7 @@ fn undo_started_hides_interrupt_hint() { /// 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(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the Review presets parent popup. chat.open_review_popup(); @@ -1486,7 +1491,7 @@ fn review_commit_picker_shows_subjects_without_timestamps() { /// 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(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.show_review_custom_prompt(); // Paste prompt text via ChatWidget handler, then submit @@ -1514,7 +1519,7 @@ fn custom_prompt_submit_sends_review_op() { /// 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(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.show_review_custom_prompt(); // Enter without any text @@ -1526,7 +1531,7 @@ fn custom_prompt_enter_empty_does_not_send() { #[test] fn view_image_tool_call_adds_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let image_path = chat.config.cwd.join("example.png"); chat.handle_codex_event(Event { @@ -1547,7 +1552,7 @@ fn view_image_tool_call_adds_history_cell() { // marker (replacing the spinner) and flushes it into history. #[test] fn interrupt_exec_marks_failed_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin a long-running command so we have an active exec cell with a spinner. begin_exec(&mut chat, "call-int", "sleep 1"); @@ -1576,7 +1581,7 @@ fn interrupt_exec_marks_failed_snapshot() { // suggesting the user to tell the model what to do differently and to use /feedback. #[test] fn interrupted_turn_error_message_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Simulate an in-progress task so the widget is in a running state. chat.handle_codex_event(Event { @@ -1607,7 +1612,7 @@ fn interrupted_turn_error_message_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, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the Review presets parent popup. chat.open_review_popup(); @@ -1642,7 +1647,7 @@ fn review_custom_prompt_escape_navigates_back_then_dismisses() { /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[tokio::test] async fn review_branch_picker_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the Review presets parent popup. chat.open_review_popup(); @@ -1729,7 +1734,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { #[test] fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.model = "gpt-5-codex".to_string(); chat.open_model_popup(); @@ -1740,7 +1745,7 @@ fn model_selection_popup_snapshot() { #[test] fn approvals_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.notices.hide_full_access_warning = None; chat.open_approvals_popup(); @@ -1779,7 +1784,7 @@ fn preset_matching_ignores_extra_writable_roots() { #[test] fn full_access_confirmation_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); let preset = builtin_approval_presets() .into_iter() @@ -1794,7 +1799,7 @@ fn full_access_confirmation_popup_snapshot() { #[cfg(target_os = "windows")] #[test] fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); let preset = builtin_approval_presets() .into_iter() @@ -1812,7 +1817,7 @@ fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { #[cfg(target_os = "windows")] #[test] fn startup_prompts_for_windows_sandbox_when_agent_requested() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); set_windows_sandbox_enabled(false); chat.config.forced_auto_mode_downgraded_on_windows = true; @@ -1834,7 +1839,7 @@ fn startup_prompts_for_windows_sandbox_when_agent_requested() { #[test] fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); @@ -1849,7 +1854,7 @@ fn model_reasoning_selection_popup_snapshot() { #[test] fn model_reasoning_selection_popup_extra_high_warning_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); @@ -1864,7 +1869,7 @@ fn model_reasoning_selection_popup_extra_high_warning_snapshot() { #[test] fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); @@ -1885,7 +1890,7 @@ fn reasoning_popup_shows_extra_high_with_space() { #[test] fn single_reasoning_option_skips_selection() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let single_effort = vec![ReasoningEffortPreset { effort: ReasoningEffortConfig::High, @@ -1925,7 +1930,7 @@ fn single_reasoning_option_skips_selection() { #[test] fn feedback_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the feedback category selection popup via slash command. chat.dispatch_command(SlashCommand::Feedback); @@ -1936,7 +1941,7 @@ fn feedback_selection_popup_snapshot() { #[test] fn feedback_upload_consent_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the consent popup directly for a chosen category. chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); @@ -1947,7 +1952,7 @@ fn feedback_upload_consent_popup_snapshot() { #[test] fn reasoning_popup_escape_returns_to_model_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.model = "gpt-5.1".to_string(); chat.open_model_popup(); @@ -1967,7 +1972,7 @@ fn reasoning_popup_escape_returns_to_model_popup() { #[test] fn exec_history_extends_previous_when_consecutive() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // 1) Start "ls -la" (List) let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la"); @@ -1998,7 +2003,7 @@ fn exec_history_extends_previous_when_consecutive() { #[test] fn user_shell_command_renders_output_not_exploring() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let begin_ls = begin_exec_with_source( &mut chat, @@ -2021,7 +2026,7 @@ fn user_shell_command_renders_output_not_exploring() { #[test] fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.bottom_pane.set_task_running(true); // Dispatch a command that is unavailable while a task runs (e.g., /model) @@ -2045,7 +2050,7 @@ fn disabled_slash_command_while_task_running_snapshot() { #[test] fn approval_modal_exec_snapshot() { // Build a chat widget with manual channels to avoid spawning the agent. - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). chat.config.approval_policy = AskForApproval::OnRequest; // Inject an exec approval request to display the approval modal. @@ -2100,7 +2105,7 @@ fn approval_modal_exec_snapshot() { // Ensures spacing looks correct when no reason text is provided. #[test] fn approval_modal_exec_without_reason_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.approval_policy = AskForApproval::OnRequest; let ev = ExecApprovalRequestEvent { @@ -2139,7 +2144,7 @@ fn approval_modal_exec_without_reason_snapshot() { // Snapshot test: patch approval modal #[test] fn approval_modal_patch_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.approval_policy = AskForApproval::OnRequest; // Build a small changeset and a reason/grant_root to exercise the prompt text. @@ -2178,7 +2183,7 @@ fn approval_modal_patch_snapshot() { #[test] fn interrupt_restores_queued_messages_into_composer() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); // Simulate a running task to enable queuing of user inputs. chat.bottom_pane.set_task_running(true); @@ -2217,7 +2222,7 @@ fn interrupt_restores_queued_messages_into_composer() { #[test] fn interrupt_prepends_queued_messages_before_existing_composer_text() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); chat.bottom_pane.set_task_running(true); chat.bottom_pane @@ -2255,7 +2260,7 @@ fn interrupt_prepends_queued_messages_before_existing_composer_text() { fn ui_snapshots_small_heights_idle() { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (chat, _rx, _op_rx) = make_chatwidget_manual(); + let (chat, _rx, _op_rx) = make_chatwidget_manual(None); for h in [1u16, 2, 3] { let name = format!("chat_small_idle_h{h}"); let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); @@ -2272,7 +2277,7 @@ fn ui_snapshots_small_heights_idle() { fn ui_snapshots_small_heights_task_running() { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Activate status line chat.handle_codex_event(Event { id: "task-1".into(), @@ -2303,7 +2308,7 @@ fn ui_snapshots_small_heights_task_running() { fn status_widget_and_approval_modal_snapshot() { use codex_core::protocol::ExecApprovalRequestEvent; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Begin a running task so the status indicator would be active. chat.handle_codex_event(Event { id: "task-1".into(), @@ -2356,7 +2361,7 @@ fn status_widget_and_approval_modal_snapshot() { // Ensures the VT100 rendering of the status indicator is stable when active. #[test] fn status_widget_active_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Activate the status indicator by simulating a task start. chat.handle_codex_event(Event { id: "task-1".into(), @@ -2383,7 +2388,7 @@ fn status_widget_active_snapshot() { #[test] fn background_event_updates_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "bg-1".into(), @@ -2399,7 +2404,7 @@ fn background_event_updates_status_header() { #[test] fn apply_patch_events_emit_history_cells() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // 1) Approval request -> proposed patch summary cell let mut changes = HashMap::new(); @@ -2497,7 +2502,7 @@ fn apply_patch_events_emit_history_cells() { #[test] fn apply_patch_manual_approval_adjusts_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -2546,7 +2551,7 @@ fn apply_patch_manual_approval_adjusts_header() { #[test] fn apply_patch_manual_flow_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -2599,7 +2604,7 @@ fn apply_patch_manual_flow_snapshot() { #[test] fn apply_patch_approval_sends_op_with_submission_id() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Simulate receiving an approval request with a distinct submission id and call id let mut changes = HashMap::new(); changes.insert( @@ -2638,7 +2643,7 @@ fn apply_patch_approval_sends_op_with_submission_id() { #[test] fn apply_patch_full_flow_integration_like() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); // 1) Backend requests approval let mut changes = HashMap::new(); @@ -2716,7 +2721,7 @@ fn apply_patch_full_flow_integration_like() { #[test] fn apply_patch_untrusted_shows_approval_modal() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Ensure approval policy is untrusted (OnRequest) chat.config.approval_policy = AskForApproval::OnRequest; @@ -2761,7 +2766,7 @@ fn apply_patch_untrusted_shows_approval_modal() { #[test] fn apply_patch_request_shows_diff_summary() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Ensure we are in OnRequest so an approval is surfaced chat.config.approval_policy = AskForApproval::OnRequest; @@ -2827,7 +2832,7 @@ fn apply_patch_request_shows_diff_summary() { #[test] fn plan_update_renders_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let update = UpdatePlanArgs { explanation: Some("Adapting plan".to_string()), plan: vec![ @@ -2863,7 +2868,7 @@ fn plan_update_renders_history_cell() { #[test] fn stream_error_updates_status_indicator() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.bottom_pane.set_task_running(true); let msg = "Reconnecting... 2/5"; chat.handle_codex_event(Event { @@ -2888,7 +2893,7 @@ fn stream_error_updates_status_indicator() { #[test] fn warning_event_adds_warning_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::Warning(WarningEvent { @@ -2907,7 +2912,7 @@ fn warning_event_adds_warning_history_cell() { #[test] fn stream_recovery_restores_previous_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "task".into(), msg: EventMsg::TaskStarted(TaskStartedEvent { @@ -2940,7 +2945,7 @@ fn stream_recovery_restores_previous_status_header() { #[test] fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin turn chat.handle_codex_event(Event { @@ -2994,7 +2999,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { #[test] fn final_reasoning_then_message_without_deltas_are_rendered() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // No deltas; only final reasoning followed by final message. chat.handle_codex_event(Event { @@ -3021,7 +3026,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() { #[test] fn deltas_then_same_final_message_are_rendered_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Stream some reasoning deltas first. chat.handle_codex_event(Event { @@ -3085,7 +3090,7 @@ 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() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); 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() }), @@ -3177,7 +3182,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { // E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks #[test] fn chatwidget_markdown_code_blocks_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Simulate a final agent message via streaming deltas instead of a single message @@ -3268,7 +3273,7 @@ printf 'fenced within fenced\n' #[test] fn chatwidget_tall() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "t1".into(), msg: EventMsg::TaskStarted(TaskStartedEvent { diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 797eded5fa1..7049d13fff1 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -7,6 +7,7 @@ use chrono::DateTime; use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; +use codex_core::openai_models::model_family::ModelFamily; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; @@ -70,6 +71,7 @@ struct StatusHistoryCell { pub(crate) fn new_status_output( config: &Config, auth_manager: &AuthManager, + model_family: &ModelFamily, total_usage: &TokenUsage, context_usage: Option<&TokenUsage>, session_id: &Option, @@ -81,6 +83,7 @@ pub(crate) fn new_status_output( let card = StatusHistoryCell::new( config, auth_manager, + model_family, total_usage, context_usage, session_id, @@ -97,6 +100,7 @@ impl StatusHistoryCell { fn new( config: &Config, auth_manager: &AuthManager, + model_family: &ModelFamily, total_usage: &TokenUsage, context_usage: Option<&TokenUsage>, session_id: &Option, @@ -119,7 +123,7 @@ impl StatusHistoryCell { let agents_summary = compose_agents_summary(config); let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); - let context_window = config.model_context_window.and_then(|window| { + let context_window = model_family.context_window.and_then(|window| { context_usage.map(|usage| StatusContextWindowData { percent_remaining: usage.percent_of_context_window_remaining(window), tokens_in_context: usage.tokens_in_context_window(), diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 35989883f13..1b16453c421 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -8,6 +8,8 @@ use codex_core::AuthManager; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +use codex_core::openai_models::model_family::ModelFamily; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; @@ -37,6 +39,10 @@ fn test_auth_manager(config: &Config) -> AuthManager { ) } +fn test_model_family(config: &Config) -> ModelFamily { + ModelsManager::construct_model_family_offline(config.model.as_str(), config) +} + fn render_lines(lines: &[Line<'static>]) -> Vec { lines .iter() @@ -124,9 +130,12 @@ fn status_snapshot_includes_reasoning_details() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); + let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -177,9 +186,11 @@ fn status_snapshot_includes_monthly_limit() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -218,9 +229,11 @@ fn status_snapshot_shows_unlimited_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -258,9 +271,11 @@ fn status_snapshot_shows_positive_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -298,9 +313,11 @@ fn status_snapshot_hides_zero_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -336,9 +353,11 @@ fn status_snapshot_hides_when_has_no_credits_flag() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -374,9 +393,11 @@ fn status_card_token_usage_excludes_cached_tokens() { .single() .expect("timestamp"); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -427,9 +448,11 @@ fn status_snapshot_truncates_in_narrow_terminal() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -469,9 +492,11 @@ fn status_snapshot_shows_missing_limits_message() { .single() .expect("timestamp"); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -529,9 +554,11 @@ fn status_snapshot_includes_credits_and_limits() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -577,9 +604,11 @@ fn status_snapshot_shows_empty_limits_message() { .expect("timestamp"); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -634,9 +663,11 @@ fn status_snapshot_shows_stale_limits_message() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -695,9 +726,11 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -742,9 +775,11 @@ fn status_context_window_uses_last_usage() { .single() .expect("timestamp"); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &total_usage, Some(&last_usage), &None, From 06704b1a0fff5bfaf500c8a3420bad3432754cc9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 8 Dec 2025 16:00:24 -0800 Subject: [PATCH 40/94] fix: pre-main hardening logic must tolerate non-UTF-8 env vars (#7749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We received a bug report that Codex CLI crashes when an env var contains a non-ASCII character, or more specifically, cannot be decoded as UTF-8: ```shell $ RUST_BACKTRACE=full RÖDBURK=1 codex thread '' panicked at library/std/src/env.rs:162:57: called `Result::unwrap()` on an `Err` value: "RÃ\xB6DBURK" stack backtrace: 0: 0x101905c18 - __mh_execute_header 1: 0x1012bd76c - __mh_execute_header 2: 0x1019050e4 - __mh_execute_header 3: 0x101905ad8 - __mh_execute_header 4: 0x101905874 - __mh_execute_header 5: 0x101904f38 - __mh_execute_header 6: 0x1019347bc - __mh_execute_header 7: 0x10193472c - __mh_execute_header 8: 0x101937884 - __mh_execute_header 9: 0x101b3bcd0 - __mh_execute_header 10: 0x101b3c0bc - __mh_execute_header 11: 0x101927a20 - __mh_execute_header 12: 0x1005c58d8 - __mh_execute_header thread '' panicked at library/core/src/panicking.rs:225:5: panic in a function that cannot unwind stack backtrace: 0: 0x101905c18 - __mh_execute_header 1: 0x1012bd76c - __mh_execute_header 2: 0x1019050e4 - __mh_execute_header 3: 0x101905ad8 - __mh_execute_header 4: 0x101905874 - __mh_execute_header 5: 0x101904f38 - __mh_execute_header 6: 0x101934794 - __mh_execute_header 7: 0x10193472c - __mh_execute_header 8: 0x101937884 - __mh_execute_header 9: 0x101b3c144 - __mh_execute_header 10: 0x101b3c1a0 - __mh_execute_header 11: 0x101b3c158 - __mh_execute_header 12: 0x1005c5ef8 - __mh_execute_header thread caused non-unwinding panic. aborting. ``` I discovered I could reproduce this on a release build, but not a dev build, so between that and the unhelpful stack trace, my mind went to the pre-`main()` logic we run in prod builds. Sure enough, we were operating on `std::env::vars()` instead of `std::env::vars_os()`, which is why the non-UTF-8 environment variable was causing an issue. This PR updates the logic to use `std::env::vars_os()` and adds a unit test. And to be extra sure, I also verified the fix works with a local release build: ``` $ cargo build --bin codex --release $ RÖDBURK=1 ./target/release/codex --version codex-cli 0.0.0 ``` --- codex-rs/Cargo.lock | 1 + codex-rs/process-hardening/Cargo.toml | 3 + codex-rs/process-hardening/src/lib.rs | 98 +++++++++++++++++++-------- 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3e7f0fd8b0d..cfb46697475 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1483,6 +1483,7 @@ name = "codex-process-hardening" version = "0.0.0" dependencies = [ "libc", + "pretty_assertions", ] [[package]] diff --git a/codex-rs/process-hardening/Cargo.toml b/codex-rs/process-hardening/Cargo.toml index 2a867572df5..7cc88ed608c 100644 --- a/codex-rs/process-hardening/Cargo.toml +++ b/codex-rs/process-hardening/Cargo.toml @@ -13,3 +13,6 @@ workspace = true [dependencies] libc = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/process-hardening/src/lib.rs b/codex-rs/process-hardening/src/lib.rs index 772647671f7..fb6145f1763 100644 --- a/codex-rs/process-hardening/src/lib.rs +++ b/codex-rs/process-hardening/src/lib.rs @@ -1,3 +1,9 @@ +#[cfg(unix)] +use std::ffi::OsString; + +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; + /// This is designed to be called pre-main() (using `#[ctor::ctor]`) to perform /// various process hardening steps, such as /// - disabling core dumps @@ -51,15 +57,7 @@ pub(crate) fn pre_main_hardening_linux() { // Official Codex releases are MUSL-linked, which means that variables such // as LD_PRELOAD are ignored anyway, but just to be sure, clear them here. - let ld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("LD_") { - Some(key) - } else { - None - } - }) - .collect(); + let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_"); for key in ld_keys { unsafe { @@ -73,15 +71,7 @@ pub(crate) fn pre_main_hardening_bsd() { // FreeBSD/OpenBSD: set RLIMIT_CORE to 0 and clear LD_* env vars set_core_file_size_limit_to_zero(); - let ld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("LD_") { - Some(key) - } else { - None - } - }) - .collect(); + let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_"); for key in ld_keys { unsafe { std::env::remove_var(key); @@ -106,15 +96,7 @@ pub(crate) fn pre_main_hardening_macos() { // Remove all DYLD_ environment variables, which can be used to subvert // library loading. - let dyld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("DYLD_") { - Some(key) - } else { - None - } - }) - .collect(); + let dyld_keys = env_keys_with_prefix(std::env::vars_os(), b"DYLD_"); for key in dyld_keys { unsafe { @@ -144,3 +126,65 @@ fn set_core_file_size_limit_to_zero() { pub(crate) fn pre_main_hardening_windows() { // TODO(mbolin): Perform the appropriate configuration for Windows. } + +#[cfg(unix)] +fn env_keys_with_prefix(vars: I, prefix: &[u8]) -> Vec +where + I: IntoIterator, +{ + vars.into_iter() + .filter_map(|(key, _)| { + key.as_os_str() + .as_bytes() + .starts_with(prefix) + .then_some(key) + }) + .collect() +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::ffi::OsStringExt; + + #[test] + fn env_keys_with_prefix_handles_non_utf8_entries() { + // RÖDBURK + let non_utf8_key1 = OsStr::from_bytes(b"R\xD6DBURK").to_os_string(); + assert!(non_utf8_key1.clone().into_string().is_err()); + let non_utf8_key2 = OsString::from_vec(vec![b'L', b'D', b'_', 0xF0]); + assert!(non_utf8_key2.clone().into_string().is_err()); + + let non_utf8_value = OsString::from_vec(vec![0xF0, 0x9F, 0x92, 0xA9]); + + let keys = env_keys_with_prefix( + vec![ + (non_utf8_key1, non_utf8_value.clone()), + (non_utf8_key2.clone(), non_utf8_value), + ], + b"LD_", + ); + assert_eq!( + keys, + vec![non_utf8_key2], + "non-UTF-8 env entries with LD_ prefix should be retained" + ); + } + + #[test] + fn env_keys_with_prefix_filters_only_matching_keys() { + let ld_test_var = OsStr::from_bytes(b"LD_TEST"); + let vars = vec![ + (OsString::from("PATH"), OsString::from("/usr/bin")), + (ld_test_var.to_os_string(), OsString::from("1")), + (OsString::from("DYLD_FOO"), OsString::from("bar")), + ]; + + let keys = env_keys_with_prefix(vars, b"LD_"); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].as_os_str(), ld_test_var); + } +} From 0f2b589d5ef37f31146345ac4e92163afc5cbd01 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 8 Dec 2025 16:09:28 -0800 Subject: [PATCH 41/94] Revert "feat: windows codesign with Azure trusted signing" (#7753) Reverts openai/codex#7675 --- .github/actions/windows-code-sign/action.yml | 54 -------------------- .github/workflows/rust-release.yml | 12 ----- 2 files changed, 66 deletions(-) delete mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml deleted file mode 100644 index 17a4fbf9995..00000000000 --- a/.github/actions/windows-code-sign/action.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: windows-code-sign -description: Sign Windows binaries with Azure Trusted Signing. -inputs: - target: - description: Target triple for the artifacts to sign. - required: true - client-id: - description: Azure Trusted Signing client ID. - required: true - tenant-id: - description: Azure tenant ID for Trusted Signing. - required: true - subscription-id: - description: Azure subscription ID for Trusted Signing. - required: true - endpoint: - description: Azure Trusted Signing endpoint. - required: true - account-name: - description: Azure Trusted Signing account name. - required: true - certificate-profile-name: - description: Certificate profile name for signing. - required: true - -runs: - using: composite - steps: - - name: Azure login for Trusted Signing (OIDC) - uses: azure/login@v2 - with: - client-id: ${{ inputs.client-id }} - tenant-id: ${{ inputs.tenant-id }} - subscription-id: ${{ inputs.subscription-id }} - - - name: Sign Windows binaries with Azure Trusted Signing - uses: azure/trusted-signing-action@v0 - with: - endpoint: ${{ inputs.endpoint }} - trusted-signing-account-name: ${{ inputs.account-name }} - certificate-profile-name: ${{ inputs.certificate-profile-name }} - exclude-environment-credential: true - exclude-workload-identity-credential: true - exclude-managed-identity-credential: true - exclude-shared-token-cache-credential: true - exclude-visual-studio-credential: true - exclude-visual-studio-code-credential: true - exclude-azure-cli-credential: false - exclude-azure-powershell-credential: true - exclude-azure-developer-cli-credential: true - exclude-interactive-browser-credential: true - files: | - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index b90f0027fa3..c3e9eeef9a3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,18 +110,6 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - - if: ${{ contains(matrix.target, 'windows') }} - name: Sign Windows binaries with Azure Trusted Signing - uses: ./.github/actions/windows-code-sign - with: - target: ${{ matrix.target }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From cacfd003acbc206c0c8d2caacd0b2ccdc4cf0232 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 17:30:42 -0800 Subject: [PATCH 42/94] override instructions using `ModelInfo` (#7754) Making sure we can override base instructions --- .../codex-api/tests/models_integration.rs | 1 + .../core/src/openai_models/model_family.rs | 2 + .../core/src/openai_models/models_manager.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 101 ++++++++++++++++++ codex-rs/protocol/src/openai_models.rs | 2 + 5 files changed, 107 insertions(+) diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 6ef328188f5..20eb64d5cd3 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -77,6 +77,7 @@ async fn models_client_hits_models_endpoint() { supported_in_api: true, priority: 1, upgrade: None, + base_instructions: None, }], etag: String::new(), }; diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 6665165ee55..094fb013702 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -102,6 +102,7 @@ impl ModelFamily { if model.slug == self.slug { self.default_reasoning_effort = Some(model.default_reasoning_level); self.shell_type = model.shell_type; + self.base_instructions = model.base_instructions.unwrap_or(self.base_instructions); } } self @@ -357,6 +358,7 @@ mod tests { supported_in_api: true, priority: 1, upgrade: None, + base_instructions: None, } } diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 9ebf0112ad2..09eedeebaf9 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -216,6 +216,7 @@ mod tests { "supported_in_api": true, "priority": priority, "upgrade": null, + "base_instructions": null, })) .expect("valid model") } diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index b13188d5d1c..0f80407473e 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -25,6 +25,7 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; +use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; @@ -67,6 +68,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { supported_in_api: true, priority: 1, upgrade: None, + base_instructions: None, }; let models_mock = mount_models_once( @@ -167,6 +169,105 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_apply_remote_base_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let model = "test-gpt-5-remote"; + + let remote_base = "Use the remote base instructions only."; + let remote_model = ModelInfo { + slug: model.to_string(), + display_name: "Parallel Remote".to_string(), + description: Some("A remote model with custom instructions".to_string()), + default_reasoning_level: ReasoningEffort::Medium, + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::ShellCommand, + visibility: ModelVisibility::List, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority: 1, + upgrade: None, + base_instructions: Some(remote_base.to_string()), + }; + mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + etag: String::new(), + }, + ) + .await; + + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::RemoteModels); + config.model = "gpt-5.1".to_string(); + }); + + let TestCodex { + codex, + cwd, + conversation_manager, + .. + } = builder.build(&server).await?; + + let models_manager = conversation_manager.get_models_manager(); + wait_for_model_available(&models_manager, model).await; + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model.to_string()), + effort: None, + summary: None, + }) + .await?; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello remote".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: model.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let body = response_mock.single_request().body_json(); + let instructions = body["instructions"].as_str().unwrap(); + assert_eq!(instructions, remote_base); + + Ok(()) +} + async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { let deadline = Instant::now() + Duration::from_secs(2); loop { diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 942303a9027..c5500f1cc5b 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -135,6 +135,8 @@ pub struct ModelInfo { pub priority: i32, #[serde(default)] pub upgrade: Option, + #[serde(default)] + pub base_instructions: Option, } /// Response wrapper for `/models`. From 68505abf0f1041e3bafe39638f2a0269318565e9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 17:42:24 -0800 Subject: [PATCH 43/94] use chatgpt provider for /models (#7756) This endpoint only exist on chatgpt --- codex-rs/core/src/openai_models/models_manager.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 09eedeebaf9..03dbd39d3d4 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -1,6 +1,7 @@ use chrono::Utc; use codex_api::ModelsClient; use codex_api::ReqwestTransport; +use codex_app_server_protocol::AuthMode; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; @@ -61,7 +62,7 @@ impl ModelsManager { } let auth = self.auth_manager.auth(); - let api_provider = provider.to_api_provider(auth.as_ref().map(|auth| auth.mode))?; + let api_provider = provider.to_api_provider(Some(AuthMode::ChatGPT))?; let api_auth = auth_provider_from_auth(auth.clone(), provider).await?; let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); From 933e247e9f68f11ec4d89a39654b381c778b8f2e Mon Sep 17 00:00:00 2001 From: muyuanjin Date: Tue, 9 Dec 2025 10:45:20 +0800 Subject: [PATCH 44/94] Fix transcript pager page continuity (#7363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Fix PageUp/PageDown behaviour in the Ctrl+T transcript overlay so that paging is continuous and reversible, and add tests to lock in the expected behaviour. ## Why Today, paging in the transcript overlay uses the raw viewport height instead of the effective content height after layout. Because the overlay reserves some rows for chrome (header/footer), this can cause: - PageDown to skip transcript lines between pages. - PageUp/PageDown not to “round-trip” cleanly (PageDown then PageUp does not always return to the same set of visible lines). This shows up when inspecting longer transcripts via Ctrl+T; see #7356 for context. ## How - Add a dedicated `PagerView::page_step` helper that computes the page size from the last rendered content height and falls back to `content_area(viewport_area).height` when that is not yet available. - Use `page_step(...)` for both PageUp and PageDown (including SPACE) so the scroll step always matches the actual content area height, not the full viewport height. - Add a focused test `transcript_overlay_paging_is_continuous_and_round_trips` that: - Renders a synthetic transcript with numbered `line-NN` rows. - Asserts that successive PageDown operations show continuous line numbers (no gaps). - Asserts that PageDown+PageUp and PageUp+PageDown round-trip correctly from non-edge offsets. The change is limited to `codex-rs/tui/src/pager_overlay.rs` and only affects the transcript overlay paging semantics. ## Related issue - #7356 ## Testing On Windows 11, using PowerShell 7 in the repo root: ```powershell cargo test cargo clippy --tests cargo fmt -- --config imports_granularity=Item ``` - All tests passed. - `cargo clippy --tests` reported some pre-existing warnings that are unrelated to this change; no new lints were introduced in the modified code. --------- Signed-off-by: muyuanjin <24222808+muyuanjin@users.noreply.github.com> Co-authored-by: Eric Traut --- codex-rs/tui/src/pager_overlay.rs | 112 ++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 3b47e9a70ef..f5854d55458 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -241,12 +241,12 @@ impl PagerView { self.scroll_offset = self.scroll_offset.saturating_add(1); } e if KEY_PAGE_UP.is_press(e) => { - let area = self.content_area(tui.terminal.viewport_area); - self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize); + let page_height = self.page_height(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_sub(page_height); } e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => { - let area = self.content_area(tui.terminal.viewport_area); - self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize); + let page_height = self.page_height(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_add(page_height); } e if KEY_HOME.is_press(e) => { self.scroll_offset = 0; @@ -263,6 +263,16 @@ impl PagerView { Ok(()) } + /// Returns the height of one page in content rows. + /// + /// Prefers the last rendered content height (excluding header/footer chrome); + /// if no render has occurred yet, falls back to the content area height + /// computed from the given viewport. + fn page_height(&self, viewport_area: Rect) -> usize { + self.last_content_height + .unwrap_or_else(|| self.content_area(viewport_area).height as usize) + } + fn update_last_content_height(&mut self, height: u16) { self.last_content_height = Some(height as usize); } @@ -812,6 +822,100 @@ mod tests { assert_snapshot!(term.backend()); } + /// Render transcript overlay and return visible line numbers (`line-NN`) in order. + fn transcript_line_numbers(overlay: &mut TranscriptOverlay, area: Rect) -> Vec { + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let top_h = area.height.saturating_sub(3); + let top = Rect::new(area.x, area.y, area.width, top_h); + let content_area = overlay.view.content_area(top); + + let mut nums = Vec::new(); + for y in content_area.y..content_area.bottom() { + let mut line = String::new(); + for x in content_area.x..content_area.right() { + line.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if let Some(n) = line + .split_whitespace() + .find_map(|w| w.strip_prefix("line-")) + .and_then(|s| s.parse().ok()) + { + nums.push(n); + } + } + nums + } + + #[test] + fn transcript_overlay_paging_is_continuous_and_round_trips() { + let mut overlay = TranscriptOverlay::new( + (0..50) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line-{i:02}"))], + }) as Arc + }) + .collect(), + ); + let area = Rect::new(0, 0, 40, 15); + + // Prime layout so last_content_height is populated and paging uses the real content height. + let mut buf = Buffer::empty(area); + overlay.view.scroll_offset = 0; + overlay.render(area, &mut buf); + let page_height = overlay.view.page_height(area); + + // Scenario 1: starting from the top, PageDown should show the next page of content. + overlay.view.scroll_offset = 0; + let page1 = transcript_line_numbers(&mut overlay, area); + let page1_len = page1.len(); + let expected_page1: Vec = (0..page1_len).collect(); + assert_eq!( + page1, expected_page1, + "first page should start at line-00 and show a full page of content" + ); + + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height); + let page2 = transcript_line_numbers(&mut overlay, area); + assert_eq!( + page2.len(), + page1_len, + "second page should have the same number of visible lines as the first page" + ); + let expected_page2_first = *page1.last().unwrap() + 1; + assert_eq!( + page2[0], expected_page2_first, + "second page after PageDown should immediately follow the first page" + ); + + // Scenario 2: from an interior offset (start=3), PageDown then PageUp should round-trip. + let interior_offset = 3usize; + overlay.view.scroll_offset = interior_offset; + let before = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height); + let _ = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height); + let after = transcript_line_numbers(&mut overlay, area); + assert_eq!( + before, after, + "PageDown+PageUp from interior offset ({interior_offset}) should round-trip" + ); + + // Scenario 3: from the top of the second page, PageUp then PageDown should round-trip. + overlay.view.scroll_offset = page_height; + let before2 = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height); + let _ = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height); + let after2 = transcript_line_numbers(&mut overlay, area); + assert_eq!( + before2, after2, + "PageUp+PageDown from the top of the second page should round-trip" + ); + } + #[test] fn static_overlay_wraps_long_lines() { let mut overlay = StaticOverlay::with_title( From 80140c6d9d272aeccc115beef4197e56a1e565ee Mon Sep 17 00:00:00 2001 From: cassirer-openai Date: Tue, 9 Dec 2025 14:56:23 +0700 Subject: [PATCH 45/94] Use codex-max prompt/tools for experimental models. (#7765) --- codex-rs/core/src/openai_models/model_family.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 094fb013702..33a130cb3dc 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -220,22 +220,18 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Tokens(10_000), ) - // Internal models. - } else if slug.starts_with("codex-exp-") { + // Experimental models. + } else if slug.starts_with("exp-codex") { + // Same as gpt-5.1-codex-max. model_family!( slug, slug, supports_reasoning_summaries: true, reasoning_summary_format: ReasoningSummaryFormat::Experimental, - base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), - experimental_supported_tools: vec![ - "grep_files".to_string(), - "list_dir".to_string(), - "read_file".to_string(), - ], shell_type: ConfigShellToolType::ShellCommand, supports_parallel_tool_calls: true, - support_verbosity: true, + support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), context_window: Some(CONTEXT_WINDOW_272K), ) From 6382dc2338aa048de7a4ecf55dc234080231ee1d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 9 Dec 2025 17:00:56 +0000 Subject: [PATCH 46/94] chore: enable parallel tc (#7589) --- codex-rs/core/src/codex.rs | 14 ++------------ codex-rs/core/src/features.rs | 6 ++++++ codex-rs/core/src/openai_models/model_family.rs | 4 ++-- codex-rs/core/templates/parallel/instructions.md | 13 ------------- 4 files changed, 10 insertions(+), 27 deletions(-) delete mode 100644 codex-rs/core/templates/parallel/instructions.md diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 74ef11c3238..80609291490 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2174,21 +2174,11 @@ async fn run_turn( .get_model_family() .supports_parallel_tool_calls; - // TODO(jif) revert once testing phase is done. - let parallel_tool_calls = model_supports_parallel && sess.enabled(Feature::ParallelToolCalls); - let mut base_instructions = turn_context.base_instructions.clone(); - if parallel_tool_calls { - static INSTRUCTIONS: &str = include_str!("../templates/parallel/instructions.md"); - let family = turn_context.client.get_model_family(); - let mut new_instructions = base_instructions.unwrap_or(family.base_instructions); - new_instructions.push_str(INSTRUCTIONS); - base_instructions = Some(new_instructions); - } let prompt = Prompt { input, tools: router.specs(), - parallel_tool_calls, - base_instructions_override: base_instructions, + parallel_tool_calls: model_supports_parallel && sess.enabled(Feature::ParallelToolCalls), + base_instructions_override: turn_context.base_instructions.clone(), output_schema: turn_context.final_output_json_schema.clone(), }; diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 69442815e70..43a89480f43 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -268,6 +268,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ParallelToolCalls, + key: "parallel", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::ViewImageTool, key: "view_image_tool", diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 33a130cb3dc..8a3853d60bf 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -259,7 +259,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), shell_type: ConfigShellToolType::ShellCommand, - supports_parallel_tool_calls: true, + supports_parallel_tool_calls: false, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), context_window: Some(CONTEXT_WINDOW_272K), @@ -275,7 +275,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), shell_type: ConfigShellToolType::ShellCommand, - supports_parallel_tool_calls: true, + supports_parallel_tool_calls: false, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), context_window: Some(CONTEXT_WINDOW_272K), diff --git a/codex-rs/core/templates/parallel/instructions.md b/codex-rs/core/templates/parallel/instructions.md deleted file mode 100644 index 292d585e45c..00000000000 --- a/codex-rs/core/templates/parallel/instructions.md +++ /dev/null @@ -1,13 +0,0 @@ - -## Exploration and reading files - -- **Think first.** Before any tool call, decide ALL files/resources you will need. -- **Batch everything.** If you need multiple files (even from different places), read them together. -- **multi_tool_use.parallel** Use `multi_tool_use.parallel` to parallelize tool calls and only this. -- **Only make sequential calls if you truly cannot know the next file without seeing a result first.** -- **Workflow:** (a) plan all needed reads → (b) issue one parallel batch → (c) analyze results → (d) repeat if new, unpredictable reads arise. - -**Additional notes**: -* Always maximize parallelism. Never read files one-by-one unless logically unavoidable. -* This concern every read/list/search operations including, but not only, `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, `wc`, ... -* Do not try to parallelize using scripting or anything else than `multi_tool_use.parallel`. From 2237b701b6eb5455140bd50cd522c5625691fc3f Mon Sep 17 00:00:00 2001 From: Tyler Anton Date: Tue, 9 Dec 2025 09:04:36 -0800 Subject: [PATCH 47/94] Fix Nix cargo output hashes for rmcp and filedescriptor (#7762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7759: - Drop the stale `rmcp` entry from `codex-rs/default.nix`’s `cargoLock.outputHashes` since the crate now comes from crates.io and no longer needs a git hash. - Add the missing hash for the filedescriptor-0.8.3 git dependency (from `pakrym/wezterm`) so `buildRustPackage` can vendor it. --- codex-rs/default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/default.nix b/codex-rs/default.nix index 867e57ee2d0..819ca7aad27 100644 --- a/codex-rs/default.nix +++ b/codex-rs/default.nix @@ -22,7 +22,7 @@ rustPlatform.buildRustPackage (_: { cargoLock.outputHashes = { "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; "crossterm-0.28.1" = "sha256-6qCtfSMuXACKFb9ATID39XyFDIEMFDmbx6SSmNe+728="; - "rmcp-0.9.0" = "sha256-0iPrpf0Ha/facO3p5e0hUKHBqGp/iS+C+OdS+pRKMOU="; + "filedescriptor-0.8.3" = "sha256-aIbzfHYjPDzWSZrgbauezGzg6lm3frhyBbU01gTQpaE="; }; meta = with lib; { From 164265bed1eb0d0ebf453196c39c79809d4f1b7a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 9 Dec 2025 09:23:51 -0800 Subject: [PATCH 48/94] Vendor ConPtySystem (#7656) The repo we were depending on is very large and we need very small part of it. --------- Co-authored-by: Pavel --- .codespellignore | 1 + codex-rs/Cargo.lock | 13 +- codex-rs/Cargo.toml | 1 - codex-rs/utils/pty/Cargo.toml | 16 + codex-rs/utils/pty/src/lib.rs | 16 +- codex-rs/utils/pty/src/win/conpty.rs | 144 +++++++++ codex-rs/utils/pty/src/win/mod.rs | 169 ++++++++++ codex-rs/utils/pty/src/win/procthreadattr.rs | 91 ++++++ codex-rs/utils/pty/src/win/psuedocon.rs | 322 +++++++++++++++++++ third_party/wezterm/LICENSE | 21 ++ 10 files changed, 789 insertions(+), 5 deletions(-) create mode 100644 codex-rs/utils/pty/src/win/conpty.rs create mode 100644 codex-rs/utils/pty/src/win/mod.rs create mode 100644 codex-rs/utils/pty/src/win/procthreadattr.rs create mode 100644 codex-rs/utils/pty/src/win/psuedocon.rs create mode 100644 third_party/wezterm/LICENSE diff --git a/.codespellignore b/.codespellignore index 546a192701b..d74f5ed86c9 100644 --- a/.codespellignore +++ b/.codespellignore @@ -1 +1,2 @@ iTerm +psuedo \ No newline at end of file diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cfb46697475..2dde6c07cdb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1673,8 +1673,13 @@ name = "codex-utils-pty" version = "0.0.0" dependencies = [ "anyhow", + "filedescriptor", + "lazy_static", + "log", "portable-pty", + "shared_library", "tokio", + "winapi", ] [[package]] @@ -2578,7 +2583,8 @@ dependencies = [ [[package]] name = "filedescriptor" version = "0.8.3" -source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ "libc", "thiserror 1.0.69", @@ -4656,7 +4662,8 @@ dependencies = [ [[package]] name = "portable-pty" version = "0.9.0" -source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" dependencies = [ "anyhow", "bitflags 1.3.2", @@ -4665,7 +4672,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix 0.29.0", + "nix 0.28.0", "serial2", "shared_library", "shell-words", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2086fbe8976..9f55f67ce34 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -289,7 +289,6 @@ opt-level = 0 # Uncomment to debug local changes. # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } -portable-pty = { git = "https://github.com/pakrym/wezterm", branch = "PSUEDOCONSOLE_INHERIT_CURSOR" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } # Uncomment to debug local changes. diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml index 2b3de5aa155..1a460ea3deb 100644 --- a/codex-rs/utils/pty/Cargo.toml +++ b/codex-rs/utils/pty/Cargo.toml @@ -11,3 +11,19 @@ workspace = true anyhow = { workspace = true } portable-pty = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } + +[target.'cfg(windows)'.dependencies] +filedescriptor = "0.8.3" +lazy_static = { workspace = true } +log = { workspace = true } +shared_library = "0.1.9" +winapi = { version = "0.3.9", features = [ + "handleapi", + "minwinbase", + "processthreadsapi", + "synchapi", + "winbase", + "wincon", + "winerror", + "winnt", +] } diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 23d69b6f6a3..dbaf4b81f76 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -7,7 +7,11 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::time::Duration; +#[cfg(windows)] +mod win; + use anyhow::Result; +#[cfg(not(windows))] use portable_pty::native_pty_system; use portable_pty::CommandBuilder; use portable_pty::MasterPty; @@ -125,6 +129,16 @@ pub struct SpawnedPty { pub exit_rx: oneshot::Receiver, } +#[cfg(windows)] +fn platform_native_pty_system() -> Box { + Box::new(win::ConPtySystem::default()) +} + +#[cfg(not(windows))] +fn platform_native_pty_system() -> Box { + native_pty_system() +} + pub async fn spawn_pty_process( program: &str, args: &[String], @@ -136,7 +150,7 @@ pub async fn spawn_pty_process( anyhow::bail!("missing program for PTY spawn"); } - let pty_system = native_pty_system(); + let pty_system = platform_native_pty_system(); let pair = pty_system.openpty(PtySize { rows: 24, cols: 80, diff --git a/codex-rs/utils/pty/src/win/conpty.rs b/codex-rs/utils/pty/src/win/conpty.rs new file mode 100644 index 00000000000..03caaa36adf --- /dev/null +++ b/codex-rs/utils/pty/src/win/conpty.rs @@ -0,0 +1,144 @@ +#![allow(clippy::unwrap_used)] + +// This file is copied from https://github.com/wezterm/wezterm (MIT license). +// Copyright (c) 2018-Present Wez Furlong +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use crate::win::psuedocon::PsuedoCon; +use anyhow::Error; +use filedescriptor::FileDescriptor; +use filedescriptor::Pipe; +use portable_pty::cmdbuilder::CommandBuilder; +use portable_pty::Child; +use portable_pty::MasterPty; +use portable_pty::PtyPair; +use portable_pty::PtySize; +use portable_pty::PtySystem; +use portable_pty::SlavePty; +use std::sync::Arc; +use std::sync::Mutex; +use winapi::um::wincon::COORD; + +#[derive(Default)] +pub struct ConPtySystem {} + +impl PtySystem for ConPtySystem { + fn openpty(&self, size: PtySize) -> anyhow::Result { + let stdin = Pipe::new()?; + let stdout = Pipe::new()?; + + let con = PsuedoCon::new( + COORD { + X: size.cols as i16, + Y: size.rows as i16, + }, + stdin.read, + stdout.write, + )?; + + let master = ConPtyMasterPty { + inner: Arc::new(Mutex::new(Inner { + con, + readable: stdout.read, + writable: Some(stdin.write), + size, + })), + }; + + let slave = ConPtySlavePty { + inner: master.inner.clone(), + }; + + Ok(PtyPair { + master: Box::new(master), + slave: Box::new(slave), + }) + } +} + +struct Inner { + con: PsuedoCon, + readable: FileDescriptor, + writable: Option, + size: PtySize, +} + +impl Inner { + pub fn resize( + &mut self, + num_rows: u16, + num_cols: u16, + pixel_width: u16, + pixel_height: u16, + ) -> Result<(), Error> { + self.con.resize(COORD { + X: num_cols as i16, + Y: num_rows as i16, + })?; + self.size = PtySize { + rows: num_rows, + cols: num_cols, + pixel_width, + pixel_height, + }; + Ok(()) + } +} + +#[derive(Clone)] +pub struct ConPtyMasterPty { + inner: Arc>, +} + +pub struct ConPtySlavePty { + inner: Arc>, +} + +impl MasterPty for ConPtyMasterPty { + fn resize(&self, size: PtySize) -> anyhow::Result<()> { + let mut inner = self.inner.lock().unwrap(); + inner.resize(size.rows, size.cols, size.pixel_width, size.pixel_height) + } + + fn get_size(&self) -> Result { + let inner = self.inner.lock().unwrap(); + Ok(inner.size) + } + + fn try_clone_reader(&self) -> anyhow::Result> { + Ok(Box::new(self.inner.lock().unwrap().readable.try_clone()?)) + } + + fn take_writer(&self) -> anyhow::Result> { + Ok(Box::new( + self.inner + .lock() + .unwrap() + .writable + .take() + .ok_or_else(|| anyhow::anyhow!("writer already taken"))?, + )) + } +} + +impl SlavePty for ConPtySlavePty { + fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result> { + let inner = self.inner.lock().unwrap(); + let child = inner.con.spawn_command(cmd)?; + Ok(Box::new(child)) + } +} diff --git a/codex-rs/utils/pty/src/win/mod.rs b/codex-rs/utils/pty/src/win/mod.rs new file mode 100644 index 00000000000..8206c9b890d --- /dev/null +++ b/codex-rs/utils/pty/src/win/mod.rs @@ -0,0 +1,169 @@ +#![allow(clippy::unwrap_used)] + +// This file is copied from https://github.com/wezterm/wezterm (MIT license). +// Copyright (c) 2018-Present Wez Furlong +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use anyhow::Context as _; +use filedescriptor::OwnedHandle; +use portable_pty::Child; +use portable_pty::ChildKiller; +use portable_pty::ExitStatus; +use std::io::Error as IoError; +use std::io::Result as IoResult; +use std::os::windows::io::AsRawHandle; +use std::pin::Pin; +use std::sync::Mutex; +use std::task::Context; +use std::task::Poll; +use winapi::shared::minwindef::DWORD; +use winapi::um::minwinbase::STILL_ACTIVE; +use winapi::um::processthreadsapi::*; +use winapi::um::synchapi::WaitForSingleObject; +use winapi::um::winbase::INFINITE; + +pub mod conpty; +mod procthreadattr; +mod psuedocon; + +pub use conpty::ConPtySystem; + +#[derive(Debug)] +pub struct WinChild { + proc: Mutex, +} + +impl WinChild { + fn is_complete(&mut self) -> IoResult> { + let mut status: DWORD = 0; + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) }; + if res != 0 { + if status == STILL_ACTIVE { + Ok(None) + } else { + Ok(Some(ExitStatus::with_exit_code(status))) + } + } else { + Ok(None) + } + } + + fn do_kill(&mut self) -> IoResult<()> { + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + let res = unsafe { TerminateProcess(proc.as_raw_handle() as _, 1) }; + let err = IoError::last_os_error(); + if res != 0 { + Err(err) + } else { + Ok(()) + } + } +} + +impl ChildKiller for WinChild { + fn kill(&mut self) -> IoResult<()> { + self.do_kill().ok(); + Ok(()) + } + + fn clone_killer(&self) -> Box { + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + Box::new(WinChildKiller { proc }) + } +} + +#[derive(Debug)] +pub struct WinChildKiller { + proc: OwnedHandle, +} + +impl ChildKiller for WinChildKiller { + fn kill(&mut self) -> IoResult<()> { + let res = unsafe { TerminateProcess(self.proc.as_raw_handle() as _, 1) }; + let err = IoError::last_os_error(); + if res != 0 { + Err(err) + } else { + Ok(()) + } + } + + fn clone_killer(&self) -> Box { + let proc = self.proc.try_clone().unwrap(); + Box::new(WinChildKiller { proc }) + } +} + +impl Child for WinChild { + fn try_wait(&mut self) -> IoResult> { + self.is_complete() + } + + fn wait(&mut self) -> IoResult { + if let Ok(Some(status)) = self.try_wait() { + return Ok(status); + } + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + unsafe { + WaitForSingleObject(proc.as_raw_handle() as _, INFINITE); + } + let mut status: DWORD = 0; + let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) }; + if res != 0 { + Ok(ExitStatus::with_exit_code(status)) + } else { + Err(IoError::last_os_error()) + } + } + + fn process_id(&self) -> Option { + let res = unsafe { GetProcessId(self.proc.lock().unwrap().as_raw_handle() as _) }; + if res == 0 { + None + } else { + Some(res) + } + } + + fn as_raw_handle(&self) -> Option { + let proc = self.proc.lock().unwrap(); + Some(proc.as_raw_handle()) + } +} + +impl std::future::Future for WinChild { + type Output = anyhow::Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> { + match self.is_complete() { + Ok(Some(status)) => Poll::Ready(Ok(status)), + Err(err) => Poll::Ready(Err(err).context("Failed to retrieve process exit status")), + Ok(None) => { + let proc = self.proc.lock().unwrap().try_clone()?; + let waker = cx.waker().clone(); + std::thread::spawn(move || { + unsafe { + WaitForSingleObject(proc.as_raw_handle() as _, INFINITE); + } + waker.wake(); + }); + Poll::Pending + } + } + } +} diff --git a/codex-rs/utils/pty/src/win/procthreadattr.rs b/codex-rs/utils/pty/src/win/procthreadattr.rs new file mode 100644 index 00000000000..6d464e99de4 --- /dev/null +++ b/codex-rs/utils/pty/src/win/procthreadattr.rs @@ -0,0 +1,91 @@ +#![allow(clippy::uninit_vec)] + +// This file is copied from https://github.com/wezterm/wezterm (MIT license). +// Copyright (c) 2018-Present Wez Furlong +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use super::psuedocon::HPCON; +use anyhow::ensure; +use anyhow::Error; +use std::io::Error as IoError; +use std::mem; +use std::ptr; +use winapi::shared::minwindef::DWORD; +use winapi::um::processthreadsapi::*; + +const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x00020016; + +pub struct ProcThreadAttributeList { + data: Vec, +} + +impl ProcThreadAttributeList { + pub fn with_capacity(num_attributes: DWORD) -> Result { + let mut bytes_required: usize = 0; + unsafe { + InitializeProcThreadAttributeList( + ptr::null_mut(), + num_attributes, + 0, + &mut bytes_required, + ) + }; + let mut data = Vec::with_capacity(bytes_required); + unsafe { data.set_len(bytes_required) }; + + let attr_ptr = data.as_mut_slice().as_mut_ptr() as *mut _; + let res = unsafe { + InitializeProcThreadAttributeList(attr_ptr, num_attributes, 0, &mut bytes_required) + }; + ensure!( + res != 0, + "InitializeProcThreadAttributeList failed: {}", + IoError::last_os_error() + ); + Ok(Self { data }) + } + + pub fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.data.as_mut_slice().as_mut_ptr() as *mut _ + } + + pub fn set_pty(&mut self, con: HPCON) -> Result<(), Error> { + let res = unsafe { + UpdateProcThreadAttribute( + self.as_mut_ptr(), + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + con, + mem::size_of::(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + ensure!( + res != 0, + "UpdateProcThreadAttribute failed: {}", + IoError::last_os_error() + ); + Ok(()) + } +} + +impl Drop for ProcThreadAttributeList { + fn drop(&mut self) { + unsafe { DeleteProcThreadAttributeList(self.as_mut_ptr()) }; + } +} diff --git a/codex-rs/utils/pty/src/win/psuedocon.rs b/codex-rs/utils/pty/src/win/psuedocon.rs new file mode 100644 index 00000000000..a8db98eefe2 --- /dev/null +++ b/codex-rs/utils/pty/src/win/psuedocon.rs @@ -0,0 +1,322 @@ +#![allow(clippy::expect_used)] +#![allow(clippy::upper_case_acronyms)] + +// This file is copied from https://github.com/wezterm/wezterm (MIT license). +// Copyright (c) 2018-Present Wez Furlong +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use super::WinChild; +use crate::win::procthreadattr::ProcThreadAttributeList; +use anyhow::bail; +use anyhow::ensure; +use anyhow::Error; +use filedescriptor::FileDescriptor; +use filedescriptor::OwnedHandle; +use lazy_static::lazy_static; +use portable_pty::cmdbuilder::CommandBuilder; +use shared_library::shared_library; +use std::env; +use std::ffi::OsStr; +use std::ffi::OsString; +use std::io::Error as IoError; +use std::mem; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::ffi::OsStringExt; +use std::os::windows::io::AsRawHandle; +use std::os::windows::io::FromRawHandle; +use std::path::Path; +use std::ptr; +use std::sync::Mutex; +use winapi::shared::minwindef::DWORD; +use winapi::shared::winerror::HRESULT; +use winapi::shared::winerror::S_OK; +use winapi::um::handleapi::*; +use winapi::um::processthreadsapi::*; +use winapi::um::winbase::CREATE_UNICODE_ENVIRONMENT; +use winapi::um::winbase::EXTENDED_STARTUPINFO_PRESENT; +use winapi::um::winbase::STARTF_USESTDHANDLES; +use winapi::um::winbase::STARTUPINFOEXW; +use winapi::um::wincon::COORD; +use winapi::um::winnt::HANDLE; + +pub type HPCON = HANDLE; + +pub const PSEUDOCONSOLE_RESIZE_QUIRK: DWORD = 0x2; +#[allow(dead_code)] +pub const PSEUDOCONSOLE_PASSTHROUGH_MODE: DWORD = 0x8; + +shared_library!(ConPtyFuncs, + pub fn CreatePseudoConsole( + size: COORD, + hInput: HANDLE, + hOutput: HANDLE, + flags: DWORD, + hpc: *mut HPCON + ) -> HRESULT, + pub fn ResizePseudoConsole(hpc: HPCON, size: COORD) -> HRESULT, + pub fn ClosePseudoConsole(hpc: HPCON), +); + +fn load_conpty() -> ConPtyFuncs { + let kernel = ConPtyFuncs::open(Path::new("kernel32.dll")).expect( + "this system does not support conpty. Windows 10 October 2018 or newer is required", + ); + + if let Ok(sideloaded) = ConPtyFuncs::open(Path::new("conpty.dll")) { + sideloaded + } else { + kernel + } +} + +lazy_static! { + static ref CONPTY: ConPtyFuncs = load_conpty(); +} + +pub struct PsuedoCon { + con: HPCON, +} + +unsafe impl Send for PsuedoCon {} +unsafe impl Sync for PsuedoCon {} + +impl Drop for PsuedoCon { + fn drop(&mut self) { + unsafe { (CONPTY.ClosePseudoConsole)(self.con) }; + } +} + +impl PsuedoCon { + pub fn new(size: COORD, input: FileDescriptor, output: FileDescriptor) -> Result { + let mut con: HPCON = INVALID_HANDLE_VALUE; + let result = unsafe { + (CONPTY.CreatePseudoConsole)( + size, + input.as_raw_handle() as _, + output.as_raw_handle() as _, + PSEUDOCONSOLE_RESIZE_QUIRK, + &mut con, + ) + }; + ensure!( + result == S_OK, + "failed to create psuedo console: HRESULT {result}" + ); + Ok(Self { con }) + } + + pub fn resize(&self, size: COORD) -> Result<(), Error> { + let result = unsafe { (CONPTY.ResizePseudoConsole)(self.con, size) }; + ensure!( + result == S_OK, + "failed to resize console to {}x{}: HRESULT: {}", + size.X, + size.Y, + result + ); + Ok(()) + } + + pub fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result { + let mut si: STARTUPINFOEXW = unsafe { mem::zeroed() }; + si.StartupInfo.cb = mem::size_of::() as u32; + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = INVALID_HANDLE_VALUE; + si.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE; + si.StartupInfo.hStdError = INVALID_HANDLE_VALUE; + + let mut attrs = ProcThreadAttributeList::with_capacity(1)?; + attrs.set_pty(self.con)?; + si.lpAttributeList = attrs.as_mut_ptr(); + + let mut pi: PROCESS_INFORMATION = unsafe { mem::zeroed() }; + + let (mut exe, mut cmdline) = build_cmdline(&cmd)?; + let cmd_os = OsString::from_wide(&cmdline); + + let cwd = resolve_current_directory(&cmd); + let mut env_block = build_environment_block(&cmd); + + let res = unsafe { + CreateProcessW( + exe.as_mut_ptr(), + cmdline.as_mut_ptr(), + ptr::null_mut(), + ptr::null_mut(), + 0, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + env_block.as_mut_ptr() as *mut _, + cwd.as_ref().map_or(ptr::null(), std::vec::Vec::as_ptr), + &mut si.StartupInfo, + &mut pi, + ) + }; + if res == 0 { + let err = IoError::last_os_error(); + let msg = format!( + "CreateProcessW `{:?}` in cwd `{:?}` failed: {}", + cmd_os, + cwd.as_ref().map(|c| OsString::from_wide(c)), + err + ); + log::error!("{msg}"); + bail!("{msg}"); + } + + let _main_thread = unsafe { OwnedHandle::from_raw_handle(pi.hThread as _) }; + let proc = unsafe { OwnedHandle::from_raw_handle(pi.hProcess as _) }; + + Ok(WinChild { + proc: Mutex::new(proc), + }) + } +} + +fn resolve_current_directory(cmd: &CommandBuilder) -> Option> { + let home = cmd + .get_env("USERPROFILE") + .and_then(|path| Path::new(path).is_dir().then(|| path.to_owned())); + let cwd = cmd + .get_cwd() + .and_then(|path| Path::new(path).is_dir().then(|| path.to_owned())); + let dir = cwd.or(home)?; + + let mut wide = Vec::new(); + if Path::new(&dir).is_relative() { + if let Ok(current_dir) = env::current_dir() { + wide.extend(current_dir.join(&dir).as_os_str().encode_wide()); + } else { + wide.extend(dir.encode_wide()); + } + } else { + wide.extend(dir.encode_wide()); + } + wide.push(0); + Some(wide) +} + +fn build_environment_block(cmd: &CommandBuilder) -> Vec { + let mut block = Vec::new(); + for (key, value) in cmd.iter_full_env_as_str() { + block.extend(OsStr::new(key).encode_wide()); + block.push(b'=' as u16); + block.extend(OsStr::new(value).encode_wide()); + block.push(0); + } + block.push(0); + block +} + +fn build_cmdline(cmd: &CommandBuilder) -> anyhow::Result<(Vec, Vec)> { + let exe_os: OsString = if cmd.is_default_prog() { + cmd.get_env("ComSpec") + .unwrap_or(OsStr::new("cmd.exe")) + .to_os_string() + } else { + let argv = cmd.get_argv(); + let Some(first) = argv.first() else { + anyhow::bail!("missing program name"); + }; + search_path(cmd, first) + }; + + let mut cmdline = Vec::new(); + append_quoted(&exe_os, &mut cmdline); + for arg in cmd.get_argv().iter().skip(1) { + cmdline.push(' ' as u16); + ensure!( + !arg.encode_wide().any(|c| c == 0), + "invalid encoding for command line argument {arg:?}" + ); + append_quoted(arg, &mut cmdline); + } + cmdline.push(0); + + let mut exe: Vec = exe_os.encode_wide().collect(); + exe.push(0); + + Ok((exe, cmdline)) +} + +fn search_path(cmd: &CommandBuilder, exe: &OsStr) -> OsString { + if let Some(path) = cmd.get_env("PATH") { + let extensions = cmd.get_env("PATHEXT").unwrap_or(OsStr::new(".EXE")); + for path in env::split_paths(path) { + let candidate = path.join(exe); + if candidate.exists() { + return candidate.into_os_string(); + } + + for ext in env::split_paths(extensions) { + let ext = ext.to_str().unwrap_or(""); + let path = path + .join(exe) + .with_extension(ext.strip_prefix('.').unwrap_or(ext)); + if path.exists() { + return path.into_os_string(); + } + } + } + } + + exe.to_os_string() +} + +fn append_quoted(arg: &OsStr, cmdline: &mut Vec) { + if !arg.is_empty() + && !arg.encode_wide().any(|c| { + c == ' ' as u16 + || c == '\t' as u16 + || c == '\n' as u16 + || c == '\x0b' as u16 + || c == '\"' as u16 + }) + { + cmdline.extend(arg.encode_wide()); + return; + } + cmdline.push('"' as u16); + + let arg: Vec<_> = arg.encode_wide().collect(); + let mut i = 0; + while i < arg.len() { + let mut num_backslashes = 0; + while i < arg.len() && arg[i] == '\\' as u16 { + i += 1; + num_backslashes += 1; + } + + if i == arg.len() { + for _ in 0..num_backslashes * 2 { + cmdline.push('\\' as u16); + } + break; + } else if arg[i] == b'"' as u16 { + for _ in 0..num_backslashes * 2 + 1 { + cmdline.push('\\' as u16); + } + cmdline.push(arg[i]); + } else { + for _ in 0..num_backslashes { + cmdline.push('\\' as u16); + } + cmdline.push(arg[i]); + } + i += 1; + } + cmdline.push('"' as u16); +} diff --git a/third_party/wezterm/LICENSE b/third_party/wezterm/LICENSE new file mode 100644 index 00000000000..d6c7256999f --- /dev/null +++ b/third_party/wezterm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-Present Wez Furlong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From a7e3e37da8c20bf4d0910c90457242864d8eb790 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 9 Dec 2025 09:24:01 -0800 Subject: [PATCH 49/94] fix: allow sendmsg(2) and recvmsg(2) syscalls in our Linux sandbox (#7779) This changes our default Landlock policy to allow `sendmsg(2)` and `recvmsg(2)` syscalls. We believe these were originally denied out of an abundance of caution, but given that `send(2)` nor `recv(2)` are allowed today [which provide comparable capability to the `*msg` equivalents], we do not believe allowing them grants any privileges beyond what we already allow. Rather than using the syscall as the security boundary, preventing access to the potentially hazardous file descriptor in the first place seems like the right layer of defense. In particular, this makes it possible for `shell-tool-mcp` to run on Linux when using a read-only sandbox for the Bash process, as demonstrated by `accept_elicitation_for_prompt_rule()` now succeeding in CI. --- codex-rs/exec-server/tests/suite/mod.rs | 6 +----- codex-rs/linux-sandbox/src/landlock.rs | 2 -- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/codex-rs/exec-server/tests/suite/mod.rs b/codex-rs/exec-server/tests/suite/mod.rs index 3a94f58579e..397a4a6f2bf 100644 --- a/codex-rs/exec-server/tests/suite/mod.rs +++ b/codex-rs/exec-server/tests/suite/mod.rs @@ -1,8 +1,4 @@ -// TODO(mbolin): Get this test working on Linux. Currently, it fails with: -// -// > Error: Mcp error: -32603: sandbox error: sandbox denied exec error, -// > exit code: 1, stdout: , stderr: Error: failed to send handshake datagram -#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +#[cfg(any(all(target_os = "macos", target_arch = "aarch64"), target_os = "linux"))] mod accept_elicitation; #[cfg(any(all(target_os = "macos", target_arch = "aarch64"), target_os = "linux"))] mod list_tools; diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 5bc96130dda..119d859b26f 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -102,12 +102,10 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), deny_syscall(libc::SYS_getsockname); deny_syscall(libc::SYS_shutdown); deny_syscall(libc::SYS_sendto); - deny_syscall(libc::SYS_sendmsg); deny_syscall(libc::SYS_sendmmsg); // NOTE: allowing recvfrom allows some tools like: `cargo clippy` to run // with their socketpair + child processes for sub-proc management // deny_syscall(libc::SYS_recvfrom); - deny_syscall(libc::SYS_recvmsg); deny_syscall(libc::SYS_recvmmsg); deny_syscall(libc::SYS_getsockopt); deny_syscall(libc::SYS_setsockopt); From 9df70a07729fac9d893a1399b37889412e595b93 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 9 Dec 2025 10:23:11 -0800 Subject: [PATCH 50/94] Add vim navigation keys to transcript pager (#7550) ## Summary - add vim-style pager navigation for transcript overlays (j/k, ctrl+f/b/d/u) without removing existing keys - add shift-space to page up ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_69309d26da508329908b2dc8ca40afb7) --- codex-rs/tui/src/key_hint.rs | 1 + codex-rs/tui/src/pager_overlay.rs | 28 ++++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 515419ee043..f277f073845 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -78,6 +78,7 @@ impl From<&KeyBinding> for Span<'static> { let modifiers = modifiers_to_string(*modifiers); let key = match key { KeyCode::Enter => "enter".to_string(), + KeyCode::Char(' ') => "space".to_string(), KeyCode::Up => "↑".to_string(), KeyCode::Down => "↓".to_string(), KeyCode::Left => "←".to_string(), diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index f5854d55458..46aaba86442 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -66,11 +66,18 @@ impl Overlay { const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up); const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down); +const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k')); +const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j')); const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp); const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown); const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); +const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); +const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); +const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); +const KEY_CTRL_U: KeyBinding = key_hint::ctrl(KeyCode::Char('u')); const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q')); const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc); const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter); @@ -234,20 +241,33 @@ impl PagerView { fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { match key_event { - e if KEY_UP.is_press(e) => { + e if KEY_UP.is_press(e) || KEY_K.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); } - e if KEY_DOWN.is_press(e) => { + e if KEY_DOWN.is_press(e) || KEY_J.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_add(1); } - e if KEY_PAGE_UP.is_press(e) => { + e if KEY_PAGE_UP.is_press(e) + || KEY_SHIFT_SPACE.is_press(e) + || KEY_CTRL_B.is_press(e) => + { let page_height = self.page_height(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_sub(page_height); } - e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => { + e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) || KEY_CTRL_F.is_press(e) => { let page_height = self.page_height(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_add(page_height); } + e if KEY_CTRL_D.is_press(e) => { + let area = self.content_area(tui.terminal.viewport_area); + let half_page = (area.height as usize).saturating_add(1) / 2; + self.scroll_offset = self.scroll_offset.saturating_add(half_page); + } + e if KEY_CTRL_U.is_press(e) => { + let area = self.content_area(tui.terminal.viewport_area); + let half_page = (area.height as usize).saturating_add(1) / 2; + self.scroll_offset = self.scroll_offset.saturating_sub(half_page); + } e if KEY_HOME.is_press(e) => { self.scroll_offset = 0; } From ac3237721eb6eb89760f0ae529144f581f3affa9 Mon Sep 17 00:00:00 2001 From: Job Chong Date: Wed, 10 Dec 2025 02:28:41 +0800 Subject: [PATCH 51/94] Fix: gracefully error out for unsupported images (#7478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix for #7459 ## What Since codex errors out for unsupported images, stop attempting to base64/attach them and instead emit a clear placeholder when the file isn’t a supported image MIME. ## Why Local uploads for unsupported formats (e.g., SVG/GIF/etc.) were dead-ending after decode failures because of the 400 retry loop. Users now get an explicit “cannot attach … unsupported image format …” response. ## How Replace the fallback read/encode path with MIME detection that bails out for non-image or unsupported image types, returning a consistent placeholder. Unreadable and invalid images still produce their existing error placeholders. --- codex-rs/Cargo.lock | 1 - codex-rs/protocol/Cargo.toml | 1 - codex-rs/protocol/src/models.rs | 86 +++++++++++++++++++++------------ 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2dde6c07cdb..0b38db1eb49 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1491,7 +1491,6 @@ name = "codex-protocol" version = "0.0.0" dependencies = [ "anyhow", - "base64", "codex-git", "codex-utils-image", "icu_decimal", diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 08f83753570..46f030c60a6 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] codex-git = { workspace = true } -base64 = { workspace = true } codex-utils-image = { workspace = true } icu_decimal = { workspace = true } icu_locale_core = { workspace = true } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index f93c157b7ca..9f66d08dca5 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; -use base64::Engine; use codex_utils_image::load_and_resize_to_fit; use mcp_types::CallToolResult; use mcp_types::ContentBlock; @@ -175,6 +174,16 @@ fn invalid_image_error_placeholder( } } +fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> ContentItem { + ContentItem::InputText { + text: format!( + "Codex cannot attach image at `{}`: unsupported image format `{}`.", + path.display(), + mime + ), + } +} + impl From for ResponseItem { fn from(item: ResponseInputItem) -> Self { match item { @@ -285,37 +294,20 @@ impl From> for ResponseInputItem { } else if err.is_invalid_image() { invalid_image_error_placeholder(&path, &err) } else { - match std::fs::read(&path) { - Ok(bytes) => { - let Some(mime_guess) = mime_guess::from_path(&path).first() - else { - return local_image_error_placeholder( - &path, - "unsupported MIME type (unknown)", - ); - }; - let mime = mime_guess.essence_str().to_owned(); - if !mime.starts_with("image/") { - return local_image_error_placeholder( - &path, - format!("unsupported MIME type `{mime}`"), - ); - } - let encoded = - base64::engine::general_purpose::STANDARD.encode(bytes); - ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - } - } - Err(read_err) => { - tracing::warn!( - "Skipping image {} – could not read file: {}", - path.display(), - read_err - ); - local_image_error_placeholder(&path, &read_err) - } + let Some(mime_guess) = mime_guess::from_path(&path).first() else { + return local_image_error_placeholder( + &path, + "unsupported MIME type (unknown)", + ); + }; + let mime = mime_guess.essence_str().to_owned(); + if !mime.starts_with("image/") { + return local_image_error_placeholder( + &path, + format!("unsupported MIME type `{mime}`"), + ); } + unsupported_image_error_placeholder(&path, &mime) } } }, @@ -823,4 +815,36 @@ mod tests { Ok(()) } + + #[test] + fn local_image_unsupported_image_format_adds_placeholder() -> Result<()> { + let dir = tempdir()?; + let svg_path = dir.path().join("example.svg"); + std::fs::write( + &svg_path, + br#" +"#, + )?; + + let item = ResponseInputItem::from(vec![UserInput::LocalImage { + path: svg_path.clone(), + }]); + + match item { + ResponseInputItem::Message { content, .. } => { + assert_eq!(content.len(), 1); + let expected = format!( + "Codex cannot attach image at `{}`: unsupported image format `image/svg+xml`.", + svg_path.display() + ); + match &content[0] { + ContentItem::InputText { text } => assert_eq!(text, &expected), + other => panic!("expected placeholder text but found {other:?}"), + } + } + other => panic!("expected message response but got {other:?}"), + } + + Ok(()) + } } From 7836aeddae57bb49dda4b1deb1445df2144cabee Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 9 Dec 2025 18:36:58 +0000 Subject: [PATCH 52/94] feat: shell snapshotting (#7641) --- codex-rs/apply-patch/src/lib.rs | 9 +- codex-rs/core/src/codex.rs | 28 +- codex-rs/core/src/environment_context.rs | 15 +- codex-rs/core/src/features.rs | 8 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/shell.rs | 43 ++ codex-rs/core/src/shell_snapshot.rs | 453 ++++++++++++++++++ codex-rs/core/src/state/service.rs | 2 +- .../core/src/tools/handlers/apply_patch.rs | 2 +- codex-rs/core/src/tools/handlers/shell.rs | 7 +- .../core/src/tools/handlers/unified_exec.rs | 56 ++- codex-rs/core/src/tools/registry.rs | 4 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/shell_snapshot.rs | 226 +++++++++ 14 files changed, 807 insertions(+), 48 deletions(-) create mode 100644 codex-rs/core/src/shell_snapshot.rs create mode 100644 codex-rs/core/tests/suite/shell_snapshot.rs diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 28dc14eb02f..fe4fe584dc9 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -112,7 +112,7 @@ fn classify_shell_name(shell: &str) -> Option { fn classify_shell(shell: &str, flag: &str) -> Option { classify_shell_name(shell).and_then(|name| match name.as_str() { - "bash" | "zsh" | "sh" if flag == "-lc" => Some(ApplyPatchShell::Unix), + "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { Some(ApplyPatchShell::PowerShell) } @@ -1097,6 +1097,13 @@ mod tests { assert_match(&heredoc_script(""), None); } + #[test] + fn test_heredoc_non_login_shell() { + let script = heredoc_script(""); + let args = strs_to_strings(&["bash", "-c", &script]); + assert_match_args(args, None); + } + #[test] fn test_heredoc_applypatch() { let args = strs_to_strings(&[ diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 80609291490..042ae1a37a5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -109,6 +109,7 @@ use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; +use crate::shell_snapshot::ShellSnapshot; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -510,7 +511,6 @@ impl Session { // - load history metadata let rollout_fut = RolloutRecorder::new(&config, rollout_params); - let default_shell = shell::default_user_shell(); let history_meta_fut = crate::message_history::history_metadata(&config); let auth_statuses_fut = compute_auth_statuses( config.mcp_servers.iter(), @@ -572,7 +572,14 @@ impl Session { config.active_profile.clone(), ); + let mut default_shell = shell::default_user_shell(); // Create the mutable state for the Session. + if config.features.enabled(Feature::ShellSnapshot) { + default_shell.shell_snapshot = + ShellSnapshot::try_new(&config.codex_home, &default_shell) + .await + .map(Arc::new); + } let state = SessionState::new(session_configuration.clone()); let services = SessionServices { @@ -581,7 +588,7 @@ impl Session { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(config.notify.clone()), rollout: Mutex::new(Some(rollout_recorder)), - user_shell: default_shell, + user_shell: Arc::new(default_shell), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager, @@ -799,14 +806,16 @@ impl Session { ) -> Option { let prev = previous?; - let prev_context = EnvironmentContext::from(prev.as_ref()); - let next_context = EnvironmentContext::from(next); + let shell = self.user_shell(); + let prev_context = EnvironmentContext::from_turn_context(prev.as_ref(), shell.as_ref()); + let next_context = EnvironmentContext::from_turn_context(next, shell.as_ref()); if prev_context.equals_except_shell(&next_context) { return None; } Some(ResponseItem::from(EnvironmentContext::diff( prev.as_ref(), next, + shell.as_ref(), ))) } @@ -1156,6 +1165,7 @@ impl Session { pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { let mut items = Vec::::with_capacity(3); + let shell = self.user_shell(); if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); } @@ -1172,7 +1182,7 @@ impl Session { Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), - self.user_shell().clone(), + shell.as_ref().clone(), ))); items } @@ -1447,8 +1457,8 @@ impl Session { &self.services.notifier } - pub(crate) fn user_shell(&self) -> &shell::Shell { - &self.services.user_shell + pub(crate) fn user_shell(&self) -> Arc { + Arc::clone(&self.services.user_shell) } fn show_raw_agent_reasoning(&self) -> bool { @@ -2878,7 +2888,7 @@ mod tests { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), - user_shell: default_user_shell(), + user_shell: Arc::new(default_user_shell()), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: auth_manager.clone(), otel_event_manager: otel_event_manager.clone(), @@ -2960,7 +2970,7 @@ mod tests { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), - user_shell: default_user_shell(), + user_shell: Arc::new(default_user_shell()), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 56e7f6cadb0..54756bda2d2 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -6,7 +6,6 @@ use crate::codex::TurnContext; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use crate::shell::Shell; -use crate::shell::default_user_shell; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -95,7 +94,7 @@ impl EnvironmentContext { && self.writable_roots == *writable_roots } - pub fn diff(before: &TurnContext, after: &TurnContext) -> Self { + pub fn diff(before: &TurnContext, after: &TurnContext, shell: &Shell) -> Self { let cwd = if before.cwd != after.cwd { Some(after.cwd.clone()) } else { @@ -111,18 +110,15 @@ impl EnvironmentContext { } else { None }; - EnvironmentContext::new(cwd, approval_policy, sandbox_policy, default_user_shell()) + EnvironmentContext::new(cwd, approval_policy, sandbox_policy, shell.clone()) } -} -impl From<&TurnContext> for EnvironmentContext { - fn from(turn_context: &TurnContext) -> Self { + pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), - // Shell is not configurable from turn to turn - default_user_shell(), + shell.clone(), ) } } @@ -201,6 +197,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, } } @@ -338,6 +335,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: "/bin/bash".into(), + shell_snapshot: None, }, ); let context2 = EnvironmentContext::new( @@ -347,6 +345,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: "/bin/zsh".into(), + shell_snapshot: None, }, ); diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 43a89480f43..d0b8e7e8da1 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -60,6 +60,8 @@ pub enum Feature { ParallelToolCalls, /// Experimental skills injection (CLI flag-driven). Skills, + /// Experimental shell snapshotting. + ShellSnapshot, } impl Feature { @@ -359,4 +361,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::ShellSnapshot, + key: "shell_snapshot", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 59dac84d26a..cd0ff497ab2 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -72,6 +72,7 @@ mod rollout; pub(crate) mod safety; pub mod seatbelt; pub mod shell; +pub mod shell_snapshot; pub mod skills; pub mod spawn; pub mod terminal; diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 2338f41cd4f..608d8063239 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,6 +1,9 @@ use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; +use std::sync::Arc; + +use crate::shell_snapshot::ShellSnapshot; #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum ShellType { @@ -15,6 +18,8 @@ pub enum ShellType { pub struct Shell { pub(crate) shell_type: ShellType, pub(crate) shell_path: PathBuf, + #[serde(skip_serializing, skip_deserializing, default)] + pub(crate) shell_snapshot: Option>, } impl Shell { @@ -58,6 +63,33 @@ impl Shell { } } } + + pub(crate) fn wrap_command_with_snapshot(&self, command: &[String]) -> Vec { + let Some(snapshot) = &self.shell_snapshot else { + return command.to_vec(); + }; + + if command.is_empty() { + return command.to_vec(); + } + + match self.shell_type { + ShellType::Zsh | ShellType::Bash | ShellType::Sh => { + let mut args = self.derive_exec_args(". \"$0\" && exec \"$@\"", false); + args.push(snapshot.path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::PowerShell => { + let mut args = + self.derive_exec_args("param($snapshot) . $snapshot; & @args", false); + args.push(snapshot.path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::Cmd => command.to_vec(), + } + } } #[cfg(unix)] @@ -134,6 +166,7 @@ fn get_zsh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Zsh, shell_path, + shell_snapshot: None, }) } @@ -143,6 +176,7 @@ fn get_bash_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Bash, shell_path, + shell_snapshot: None, }) } @@ -152,6 +186,7 @@ fn get_sh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Sh, shell_path, + shell_snapshot: None, }) } @@ -167,6 +202,7 @@ fn get_powershell_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::PowerShell, shell_path, + shell_snapshot: None, }) } @@ -176,6 +212,7 @@ fn get_cmd_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Cmd, shell_path, + shell_snapshot: None, }) } @@ -184,11 +221,13 @@ fn ultimate_fallback_shell() -> Shell { Shell { shell_type: ShellType::Cmd, shell_path: PathBuf::from("cmd.exe"), + shell_snapshot: None, } } else { Shell { shell_type: ShellType::Sh, shell_path: PathBuf::from("/bin/sh"), + shell_snapshot: None, } } } @@ -413,6 +452,7 @@ mod tests { let test_bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, }; assert_eq!( test_bash_shell.derive_exec_args("echo hello", false), @@ -426,6 +466,7 @@ mod tests { let test_zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: None, }; assert_eq!( test_zsh_shell.derive_exec_args("echo hello", false), @@ -439,6 +480,7 @@ mod tests { let test_powershell_shell = Shell { shell_type: ShellType::PowerShell, shell_path: PathBuf::from("pwsh.exe"), + shell_snapshot: None, }; assert_eq!( test_powershell_shell.derive_exec_args("echo hello", false), @@ -465,6 +507,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from(shell_path), + shell_snapshot: None, } ); } diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs new file mode 100644 index 00000000000..4df54997b72 --- /dev/null +++ b/codex-rs/core/src/shell_snapshot.rs @@ -0,0 +1,453 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use crate::shell::Shell; +use crate::shell::ShellType; +use crate::shell::get_shell; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use tokio::fs; +use tokio::process::Command; +use tokio::time::timeout; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellSnapshot { + pub path: PathBuf, +} + +const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); + +impl ShellSnapshot { + pub async fn try_new(codex_home: &Path, shell: &Shell) -> Option { + let extension = match shell.shell_type { + ShellType::PowerShell => "ps1", + _ => "sh", + }; + let path = + codex_home + .join("shell_snapshots") + .join(format!("{}.{}", Uuid::new_v4(), extension)); + match write_shell_snapshot(shell.shell_type.clone(), &path).await { + Ok(path) => { + tracing::info!("Shell snapshot successfully created: {}", path.display()); + Some(Self { path }) + } + Err(err) => { + tracing::warn!( + "Failed to create shell snapshot for {}: {err:?}", + shell.name() + ); + None + } + } + } +} + +impl Drop for ShellSnapshot { + fn drop(&mut self) { + if let Err(err) = std::fs::remove_file(&self.path) { + tracing::warn!( + "Failed to delete shell snapshot at {:?}: {err:?}", + self.path + ); + } + } +} + +pub async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result { + if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd { + bail!("Shell snapshot not supported yet for {shell_type:?}"); + } + let shell = get_shell(shell_type.clone(), None) + .with_context(|| format!("No available shell for {shell_type:?}"))?; + + let raw_snapshot = capture_snapshot(&shell).await?; + let snapshot = strip_snapshot_preamble(&raw_snapshot)?; + + if let Some(parent) = output_path.parent() { + let parent_display = parent.display(); + fs::create_dir_all(parent) + .await + .with_context(|| format!("Failed to create snapshot parent {parent_display}"))?; + } + + let snapshot_path = output_path.display(); + fs::write(output_path, snapshot) + .await + .with_context(|| format!("Failed to write snapshot to {snapshot_path}"))?; + + Ok(output_path.to_path_buf()) +} + +async fn capture_snapshot(shell: &Shell) -> Result { + let shell_type = shell.shell_type.clone(); + match shell_type { + ShellType::Zsh => run_shell_script(shell, zsh_snapshot_script()).await, + ShellType::Bash => run_shell_script(shell, bash_snapshot_script()).await, + ShellType::Sh => run_shell_script(shell, sh_snapshot_script()).await, + ShellType::PowerShell => run_shell_script(shell, powershell_snapshot_script()).await, + ShellType::Cmd => bail!("Shell snapshotting is not yet supported for {shell_type:?}"), + } +} + +fn strip_snapshot_preamble(snapshot: &str) -> Result { + let marker = "# Snapshot file"; + let Some(start) = snapshot.find(marker) else { + bail!("Snapshot output missing marker {marker}"); + }; + + Ok(snapshot[start..].to_string()) +} + +async fn run_shell_script(shell: &Shell, script: &str) -> Result { + run_shell_script_with_timeout(shell, script, SNAPSHOT_TIMEOUT).await +} + +async fn run_shell_script_with_timeout( + shell: &Shell, + script: &str, + snapshot_timeout: Duration, +) -> Result { + let args = shell.derive_exec_args(script, true); + let shell_name = shell.name(); + + // Handler is kept as guard to control the drop. The `mut` pattern is required because .args() + // returns a ref of handler. + let mut handler = Command::new(&args[0]); + handler.args(&args[1..]); + handler.kill_on_drop(true); + let output = timeout(snapshot_timeout, handler.output()) + .await + .map_err(|_| anyhow!("Snapshot command timed out for {shell_name}"))? + .with_context(|| format!("Failed to execute {shell_name}"))?; + + if !output.status.success() { + let status = output.status; + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Snapshot command exited with status {status}: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn zsh_snapshot_script() -> &'static str { + r##"print '# Snapshot file' +print '# Unset all aliases to avoid conflicts with functions' +print 'unalias -a 2>/dev/null || true' +print '# Functions' +functions +print '' +setopt_count=$(setopt | wc -l | tr -d ' ') +print "# setopts $setopt_count" +setopt | sed 's/^/setopt /' +print '' +alias_count=$(alias -L | wc -l | tr -d ' ') +print "# aliases $alias_count" +alias -L +print '' +export_count=$(export -p | wc -l | tr -d ' ') +print "# exports $export_count" +export -p +"## +} + +fn bash_snapshot_script() -> &'static str { + r##"echo '# Snapshot file' +echo '# Unset all aliases to avoid conflicts with functions' +unalias -a 2>/dev/null || true +echo '# Functions' +declare -f +echo '' +bash_opts=$(set -o | awk '$2=="on"{print $1}') +bash_opt_count=$(printf '%s\n' "$bash_opts" | sed '/^$/d' | wc -l | tr -d ' ') +echo "# setopts $bash_opt_count" +if [ -n "$bash_opts" ]; then + printf 'set -o %s\n' $bash_opts +fi +echo '' +alias_count=$(alias -p | wc -l | tr -d ' ') +echo "# aliases $alias_count" +alias -p +echo '' +export_count=$(export -p | wc -l | tr -d ' ') +echo "# exports $export_count" +export -p +"## +} + +fn sh_snapshot_script() -> &'static str { + r##"echo '# Snapshot file' +echo '# Unset all aliases to avoid conflicts with functions' +unalias -a 2>/dev/null || true +echo '# Functions' +if command -v typeset >/dev/null 2>&1; then + typeset -f +elif command -v declare >/dev/null 2>&1; then + declare -f +fi +echo '' +if set -o >/dev/null 2>&1; then + sh_opts=$(set -o | awk '$2=="on"{print $1}') + sh_opt_count=$(printf '%s\n' "$sh_opts" | sed '/^$/d' | wc -l | tr -d ' ') + echo "# setopts $sh_opt_count" + if [ -n "$sh_opts" ]; then + printf 'set -o %s\n' $sh_opts + fi +else + echo '# setopts 0' +fi +echo '' +if alias >/dev/null 2>&1; then + alias_count=$(alias | wc -l | tr -d ' ') + echo "# aliases $alias_count" + alias + echo '' +else + echo '# aliases 0' +fi +if export -p >/dev/null 2>&1; then + export_count=$(export -p | wc -l | tr -d ' ') + echo "# exports $export_count" + export -p +else + export_count=$(env | wc -l | tr -d ' ') + echo "# exports $export_count" + env | sort | while IFS='=' read -r key value; do + escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g") + printf "export %s='%s'\n" "$key" "$escaped" + done +fi +"## +} + +fn powershell_snapshot_script() -> &'static str { + r##"$ErrorActionPreference = 'Stop' +Write-Output '# Snapshot file' +Write-Output '# Unset all aliases to avoid conflicts with functions' +Write-Output 'Remove-Item Alias:* -ErrorAction SilentlyContinue' +Write-Output '# Functions' +Get-ChildItem Function: | ForEach-Object { + "function {0} {{`n{1}`n}}" -f $_.Name, $_.Definition +} +Write-Output '' +$aliases = Get-Alias +Write-Output ("# aliases " + $aliases.Count) +$aliases | ForEach-Object { + "Set-Alias -Name {0} -Value {1}" -f $_.Name, $_.Definition +} +Write-Output '' +$envVars = Get-ChildItem Env: +Write-Output ("# exports " + $envVars.Count) +$envVars | ForEach-Object { + $escaped = $_.Value -replace "'", "''" + "`$env:{0}='{1}'" -f $_.Name, $escaped +} +"## +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + #[cfg(target_os = "linux")] + use std::os::unix::fs::PermissionsExt; + #[cfg(target_os = "linux")] + use std::process::Command as StdCommand; + use std::sync::Arc; + use tempfile::tempdir; + + #[cfg(not(target_os = "windows"))] + fn assert_posix_snapshot_sections(snapshot: &str) { + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!( + snapshot.contains("PATH"), + "snapshot should capture a PATH export" + ); + assert!(snapshot.contains("setopts ")); + } + + async fn get_snapshot(shell_type: ShellType) -> Result { + let dir = tempdir()?; + let path = dir.path().join("snapshot.sh"); + write_shell_snapshot(shell_type, &path).await?; + let content = fs::read_to_string(&path).await?; + Ok(content) + } + + #[test] + fn strip_snapshot_preamble_removes_leading_output() { + let snapshot = "noise\n# Snapshot file\nexport PATH=/bin\n"; + let cleaned = strip_snapshot_preamble(snapshot).expect("snapshot marker exists"); + assert_eq!(cleaned, "# Snapshot file\nexport PATH=/bin\n"); + } + + #[test] + fn strip_snapshot_preamble_requires_marker() { + let result = strip_snapshot_preamble("missing header"); + assert!(result.is_err()); + } + + #[cfg(unix)] + #[test] + fn wrap_command_with_snapshot_wraps_bash_shell() { + let snapshot_path = PathBuf::from("/tmp/snapshot.sh"); + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: Some(Arc::new(ShellSnapshot { + path: snapshot_path.clone(), + })), + }; + let original_command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo hello".to_string(), + ]; + + let wrapped = shell.wrap_command_with_snapshot(&original_command); + + let mut expected = shell.derive_exec_args(". \"$0\" && exec \"$@\"", false); + expected.push(snapshot_path.to_string_lossy().to_string()); + expected.extend_from_slice(&original_command); + + assert_eq!(wrapped, expected); + } + + #[test] + fn wrap_command_with_snapshot_preserves_cmd_shell() { + let snapshot_path = PathBuf::from("C:\\snapshot.cmd"); + let shell = Shell { + shell_type: ShellType::Cmd, + shell_path: PathBuf::from("cmd"), + shell_snapshot: Some(Arc::new(ShellSnapshot { + path: snapshot_path, + })), + }; + let original_command = vec![ + "cmd".to_string(), + "/c".to_string(), + "echo hello".to_string(), + ]; + + let wrapped = shell.wrap_command_with_snapshot(&original_command); + + assert_eq!(wrapped, original_command); + } + + #[cfg(unix)] + #[tokio::test] + async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> { + let dir = tempdir()?; + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, + }; + + let snapshot = ShellSnapshot::try_new(dir.path(), &shell) + .await + .expect("snapshot should be created"); + let path = snapshot.path.clone(); + assert!(path.exists()); + + drop(snapshot); + + assert!(!path.exists()); + + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { + use std::process::Stdio; + let dir = tempdir()?; + let shell_path = dir.path().join("hanging-shell.sh"); + let pid_path = dir.path().join("pid"); + + let script = format!( + "#!/bin/sh\n\ + echo $$ > {}\n\ + sleep 30\n", + pid_path.display() + ); + fs::write(&shell_path, script).await?; + let mut permissions = std::fs::metadata(&shell_path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&shell_path, permissions)?; + + let shell = Shell { + shell_type: ShellType::Sh, + shell_path, + shell_snapshot: None, + }; + + let err = run_shell_script_with_timeout(&shell, "ignored", Duration::from_millis(500)) + .await + .expect_err("snapshot shell should time out"); + assert!( + err.to_string().contains("timed out"), + "expected timeout error, got {err:?}" + ); + + let pid = fs::read_to_string(&pid_path) + .await + .expect("snapshot shell writes its pid before timing out") + .trim() + .parse::()?; + + let kill_status = StdCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .status()?; + assert!( + !kill_status.success(), + "timed out snapshot shell should be terminated" + ); + + Ok(()) + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn macos_zsh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Zsh).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linux_bash_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Bash).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linux_sh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Sh).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) + } + + #[cfg(target_os = "windows")] + #[ignore] + #[tokio::test] + async fn windows_powershell_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::PowerShell).await?; + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + Ok(()) + } +} diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index a35720a9bf7..7387bcedae0 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -18,7 +18,7 @@ pub(crate) struct SessionServices { pub(crate) unified_exec_manager: UnifiedExecSessionManager, pub(crate) notifier: UserNotifier, pub(crate) rollout: Mutex>, - pub(crate) user_shell: crate::shell::Shell, + pub(crate) user_shell: Arc, pub(crate) show_raw_agent_reasoning: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 4a28619c760..5b8a04b388f 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -46,7 +46,7 @@ impl ToolHandler for ApplyPatchHandler { ) } - fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { true } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 7d13c90fa02..98bd883d134 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -76,7 +76,7 @@ impl ToolHandler for ShellHandler { ) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { match &invocation.payload { ToolPayload::Function { arguments } => { serde_json::from_str::(arguments) @@ -148,7 +148,7 @@ impl ToolHandler for ShellCommandHandler { matches!(payload, ToolPayload::Function { .. }) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { let ToolPayload::Function { arguments } = &invocation.payload else { return true; }; @@ -307,18 +307,21 @@ mod tests { let bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, }; assert_safe(&bash_shell, "ls -la"); let zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: None, }; assert_safe(&zsh_shell, "ls -la"); let powershell = Shell { shell_type: ShellType::PowerShell, shell_path: PathBuf::from("pwsh.exe"), + shell_snapshot: None, }; assert_safe(&powershell, "ls -Name"); } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 66cf624a6c2..c7230e54d73 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,12 +1,10 @@ -use std::path::PathBuf; - use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecCommandSource; use crate::protocol::ExecOutputStream; -use crate::shell::default_user_shell; +use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -24,6 +22,8 @@ use crate::unified_exec::UnifiedExecSessionManager; use crate::unified_exec::WriteStdinRequest; use async_trait::async_trait; use serde::Deserialize; +use std::path::PathBuf; +use std::sync::Arc; pub struct UnifiedExecHandler; @@ -34,8 +34,8 @@ struct ExecCommandArgs { workdir: Option, #[serde(default)] shell: Option, - #[serde(default = "default_login")] - login: bool, + #[serde(default)] + login: Option, #[serde(default = "default_exec_yield_time_ms")] yield_time_ms: u64, #[serde(default)] @@ -66,10 +66,6 @@ fn default_write_stdin_yield_time_ms() -> u64 { 250 } -fn default_login() -> bool { - true -} - #[async_trait] impl ToolHandler for UnifiedExecHandler { fn kind(&self) -> ToolKind { @@ -83,7 +79,7 @@ impl ToolHandler for UnifiedExecHandler { ) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { let (ToolPayload::Function { arguments } | ToolPayload::UnifiedExec { arguments }) = &invocation.payload else { @@ -93,7 +89,7 @@ impl ToolHandler for UnifiedExecHandler { let Ok(params) = serde_json::from_str::(arguments) else { return true; }; - let command = get_command(¶ms); + let command = get_command(¶ms, invocation.session.user_shell()); !is_known_safe_command(&command) } @@ -130,9 +126,10 @@ impl ToolHandler for UnifiedExecHandler { })?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args); + let command_for_intercept = get_command(&args, session.user_shell()); let ExecCommandArgs { workdir, + login, yield_time_ms, max_output_tokens, with_escalated_permissions, @@ -159,7 +156,7 @@ impl ToolHandler for UnifiedExecHandler { let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone()); if let Some(output) = intercept_apply_patch( - &command, + &command_for_intercept, &cwd, Some(yield_time_ms), context.session.as_ref(), @@ -180,6 +177,14 @@ impl ToolHandler for UnifiedExecHandler { &context.call_id, None, ); + let command = if login.is_none() { + context + .session + .user_shell() + .wrap_command_with_snapshot(&command_for_intercept) + } else { + command_for_intercept + }; let emitter = ToolEmitter::unified_exec( &command, cwd.clone(), @@ -254,14 +259,15 @@ impl ToolHandler for UnifiedExecHandler { } } -fn get_command(args: &ExecCommandArgs) -> Vec { - let shell = if let Some(shell_str) = &args.shell { - get_shell_by_model_provided_path(&PathBuf::from(shell_str)) - } else { - default_user_shell() - }; +fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { + if let Some(shell_str) = &args.shell { + let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); + shell.shell_snapshot = None; + return shell.derive_exec_args(&args.cmd, args.login.unwrap_or(true)); + } - shell.derive_exec_args(&args.cmd, args.login) + let use_login_shell = args.login.unwrap_or(session_shell.shell_snapshot.is_none()); + session_shell.derive_exec_args(&args.cmd, use_login_shell) } fn format_response(response: &UnifiedExecResponse) -> String { @@ -296,6 +302,8 @@ fn format_response(response: &UnifiedExecResponse) -> String { #[cfg(test)] mod tests { use super::*; + use crate::shell::default_user_shell; + use std::sync::Arc; #[test] fn test_get_command_uses_default_shell_when_unspecified() { @@ -306,7 +314,7 @@ mod tests { assert!(args.shell.is_none()); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -321,7 +329,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } @@ -335,7 +343,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("powershell")); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } @@ -349,7 +357,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index f35ff063155..9b33e84b76b 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -30,7 +30,7 @@ pub trait ToolHandler: Send + Sync { ) } - fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { false } @@ -110,7 +110,7 @@ impl ToolRegistry { let output_cell = &output_cell; let invocation = invocation; async move { - if handler.is_mutating(&invocation) { + if handler.is_mutating(&invocation).await { tracing::trace!("waiting for tool gate"); invocation.turn.tool_call_gate.wait_ready().await; tracing::trace!("tool gate released"); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 2112cbb7aaa..29cc3ffb191 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -49,6 +49,7 @@ mod rollout_list_find; mod seatbelt; mod shell_command; mod shell_serialization; +mod shell_snapshot; mod stream_error_allows_next_turn; mod stream_no_completed; mod text_encoding_fix; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs new file mode 100644 index 00000000000..f50e153ddc2 --- /dev/null +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -0,0 +1,226 @@ +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::PathBuf; +use tokio::fs; + +#[derive(Debug)] +struct SnapshotRun { + begin: ExecCommandBeginEvent, + end: ExecCommandEndEvent, + snapshot_path: PathBuf, + snapshot_content: String, + codex_home: PathBuf, +} + +#[allow(clippy::expect_used)] +async fn run_snapshot_command(command: &str) -> Result { + let builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + config.features.enable(Feature::ShellSnapshot); + }); + let harness = TestCodexHarness::with_builder(builder).await?; + let args = json!({ + "cmd": command, + "yield_time_ms": 1000, + }); + let call_id = "shell-snapshot-exec"; + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(harness.server(), responses).await; + + let test = harness.test(); + let codex = test.codex.clone(); + let codex_home = test.home.path().to_path_buf(); + let session_model = test.session_configured.model.clone(); + let cwd = test.cwd_path().to_path_buf(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run unified exec with shell snapshot".into(), + }], + final_output_json_schema: None, + cwd, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + let snapshot_arg = begin + .command + .iter() + .find(|arg| arg.contains("shell_snapshots")) + .expect("command includes shell snapshot path") + .to_owned(); + let snapshot_path = PathBuf::from(&snapshot_arg); + let snapshot_content = fs::read_to_string(&snapshot_path).await?; + + let end = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + Ok(SnapshotRun { + begin, + end, + snapshot_path, + snapshot_content, + codex_home, + }) +} + +fn normalize_newlines(text: &str) -> String { + text.replace("\r\n", "\n") +} + +fn assert_posix_snapshot_sections(snapshot: &str) { + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!(snapshot.contains("setopts ")); + assert!( + snapshot.contains("PATH"), + "snapshot should include PATH exports; snapshot={snapshot:?}" + ); +} + +#[cfg_attr(not(target_os = "linux"), ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "echo snapshot-linux"; + let run = run_snapshot_command(command).await?; + + let shell_path = run + .begin + .command + .first() + .expect("shell path recorded") + .clone(); + assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); + assert_eq!( + run.begin.command.get(2).map(String::as_str), + Some(". \"$0\" && exec \"$@\"") + ); + assert_eq!(run.begin.command.get(4), Some(&shell_path)); + assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert_posix_snapshot_sections(&run.snapshot_content); + assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-linux"); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} + +#[cfg_attr(not(target_os = "macos"), ignore)] +#[cfg_attr( + target_os = "macos", + ignore = "requires unrestricted networking on macOS" +)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn macos_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "echo snapshot-macos"; + let run = run_snapshot_command(command).await?; + + let shell_path = run + .begin + .command + .first() + .expect("shell path recorded") + .clone(); + assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); + assert_eq!( + run.begin.command.get(2).map(String::as_str), + Some(". \"$0\" && exec \"$@\"") + ); + assert_eq!(run.begin.command.get(4), Some(&shell_path)); + assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert_posix_snapshot_sections(&run.snapshot_content); + assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-macos"); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} + +// #[cfg_attr(not(target_os = "windows"), ignore)] +#[ignore] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn windows_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "Write-Output snapshot-windows"; + let run = run_snapshot_command(command).await?; + + let snapshot_index = run + .begin + .command + .iter() + .position(|arg| arg.contains("shell_snapshots")) + .expect("snapshot argument exists"); + assert!(run.begin.command.iter().any(|arg| arg == "-NoProfile")); + assert!( + run.begin + .command + .iter() + .any(|arg| arg == "param($snapshot) . $snapshot; & @args") + ); + assert!(snapshot_index > 0); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert!(run.snapshot_content.contains("# Snapshot file")); + assert!(run.snapshot_content.contains("# aliases ")); + assert!(run.snapshot_content.contains("# exports ")); + assert_eq!( + normalize_newlines(&run.end.stdout).trim(), + "snapshot-windows" + ); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} From 05e546ee1f7a2cd92d83872e32ddb52a0892938c Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Tue, 9 Dec 2025 13:23:14 -0800 Subject: [PATCH 53/94] fix more typos in execpolicy.md (#7787) --- docs/execpolicy.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/execpolicy.md b/docs/execpolicy.md index b5167a1eb4e..a2dc6d9add1 100644 --- a/docs/execpolicy.md +++ b/docs/execpolicy.md @@ -14,10 +14,10 @@ Whitelisted commands will no longer require your permission to run in current an Under the hood, when you approve and whitelist a command, codex will edit `~/.codex/policy/default.execpolicy`. -### Editing `.codexpolicy` files +### Editing `.execpolicy` files 1. Create a policy directory: `mkdir -p ~/.codex/policy`. -2. Add one or more `.codexpolicy` files in that folder. Codex automatically loads every `.codexpolicy` file in there on startup. +2. Add one or more `.execpolicy` files in that folder. Codex automatically loads every `.execpolicy` file in there on startup. 3. Write `prefix_rule` entries to describe the commands you want to allow, prompt, or block: ```starlark From 225a5f7ffb9872673df35b7978b5e2bacdc12190 Mon Sep 17 00:00:00 2001 From: Bryant Rolfe Date: Tue, 9 Dec 2025 14:41:10 -0800 Subject: [PATCH 54/94] Add vim-style navigation for CLI option selection (#7784) ## Summary Support "j" and "k" keys as aliases for "down" and "up" so vim users feel loved. Only support these keys when the selection is not searchable. ## Testing - env -u NO_COLOR TERM=xterm-256color cargo test -p codex-tui ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_693771b53bc8833088669060dfac2083) --- codex-rs/tui/src/bottom_pane/list_selection_view.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 26a32a42e1c..d23fd8ed3b6 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -284,6 +284,11 @@ impl BottomPaneView for ListSelectionView { modifiers: KeyModifiers::NONE, .. } /* ^P */ => self.move_up(), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } if !self.is_searchable => self.move_up(), KeyEvent { code: KeyCode::Down, .. @@ -298,6 +303,11 @@ impl BottomPaneView for ListSelectionView { modifiers: KeyModifiers::NONE, .. } /* ^N */ => self.move_down(), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } if !self.is_searchable => self.move_down(), KeyEvent { code: KeyCode::Backspace, .. From 0c8828c5e298359ba50ba1e9c840400614afcd45 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 9 Dec 2025 16:23:53 -0800 Subject: [PATCH 55/94] feat(tui2): add feature-flagged tui2 frontend (#7793) Introduce a new codex-tui2 crate that re-exports the existing interactive TUI surface and delegates run_main directly to codex-tui. This keeps behavior identical while giving tui2 its own crate for future viewport work. Wire the codex CLI to select the frontend via the tui2 feature flag. When the merged CLI overrides include features.tui2=true (e.g. via --enable tui2), interactive runs are routed through codex_tui2::run_main; otherwise they continue to use the original codex_tui::run_main. Register Feature::Tui2 in the core feature registry and add the tui2 crate and dependency entries so the new frontend builds alongside the existing TUI. This is a stub that only wires up the feature flag for this. image --- codex-rs/Cargo.lock | 13 +++++++++++ codex-rs/Cargo.toml | 2 ++ codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 43 +++++++++++++++++++++++++++++++++-- codex-rs/core/src/features.rs | 8 +++++++ codex-rs/tui2/Cargo.toml | 29 +++++++++++++++++++++++ codex-rs/tui2/src/lib.rs | 24 +++++++++++++++++++ codex-rs/tui2/src/main.rs | 32 ++++++++++++++++++++++++++ docs/config.md | 21 +++++++++-------- 9 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 codex-rs/tui2/Cargo.toml create mode 100644 codex-rs/tui2/src/lib.rs create mode 100644 codex-rs/tui2/src/main.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0b38db1eb49..aa1f72b4b47 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1040,6 +1040,7 @@ dependencies = [ "codex-rmcp-client", "codex-stdio-to-uds", "codex-tui", + "codex-tui2", "codex-windows-sandbox", "ctor 0.5.0", "libc", @@ -1637,6 +1638,18 @@ dependencies = [ "vt100", ] +[[package]] +name = "codex-tui2" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-arg0", + "codex-common", + "codex-core", + "codex-tui", +] + [[package]] name = "codex-utils-cache" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9f55f67ce34..bd62c72d5f7 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -34,6 +34,7 @@ members = [ "stdio-to-uds", "otel", "tui", + "tui2", "utils/git", "utils/cache", "utils/image", @@ -88,6 +89,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } +codex-tui2 = { path = "tui2" } codex-utils-cache = { path = "utils/cache" } codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 6c80a12595e..84e6e9acaf4 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -36,6 +36,7 @@ codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-tui = { workspace = true } +codex-tui2 = { workspace = true } ctor = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6cff73e86de..c3788f83f44 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -25,6 +25,7 @@ use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::update_action::UpdateAction; +use codex_tui2 as tui2; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; @@ -37,6 +38,11 @@ use crate::mcp_cmd::McpCli; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::config::find_codex_home; +use codex_core::config::load_config_as_toml_with_cli_overrides; +use codex_core::features::Feature; +use codex_core::features::FeatureOverrides; +use codex_core::features::Features; use codex_core::features::is_known_feature_key; /// Codex CLI @@ -444,7 +450,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() &mut interactive.config_overrides, root_config_overrides.clone(), ); - let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { @@ -499,7 +505,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() all, config_overrides, ); - let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { @@ -650,6 +656,39 @@ fn prepend_config_flags( .splice(0..0, cli_config_overrides.raw_overrides); } +/// Run the interactive Codex TUI, dispatching to either the legacy implementation or the +/// experimental TUI v2 shim based on feature flags resolved from config. +async fn run_interactive_tui( + interactive: TuiCli, + codex_linux_sandbox_exe: Option, +) -> std::io::Result { + if is_tui2_enabled(&interactive).await? { + tui2::run_main(interactive, codex_linux_sandbox_exe).await + } else { + codex_tui::run_main(interactive, codex_linux_sandbox_exe).await + } +} + +/// Returns `Ok(true)` when the resolved configuration enables the `tui2` feature flag. +/// +/// This performs a lightweight config load (honoring the same precedence as the lower-level TUI +/// bootstrap: `$CODEX_HOME`, config.toml, profile, and CLI `-c` overrides) solely to decide which +/// TUI frontend to launch. The full configuration is still loaded later by the interactive TUI. +async fn is_tui2_enabled(cli: &TuiCli) -> std::io::Result { + let raw_overrides = cli.config_overrides.raw_overrides.clone(); + let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; + let cli_kv_overrides = overrides_cli + .parse_overrides() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + + let codex_home = find_codex_home()?; + let config_toml = load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides).await?; + let config_profile = config_toml.get_config_profile(cli.config_profile.clone())?; + let overrides = FeatureOverrides::default(); + let features = Features::from_config(&config_toml, &config_profile, overrides); + Ok(features.enabled(Feature::Tui2)) +} + /// Build the final `TuiCli` for a `codex resume` invocation. fn finalize_resume_interactive( mut interactive: TuiCli, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index d0b8e7e8da1..d714f8e85e5 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -62,6 +62,8 @@ pub enum Feature { Skills, /// Experimental shell snapshotting. ShellSnapshot, + /// Experimental TUI v2 (viewport) implementation. + Tui2, } impl Feature { @@ -367,4 +369,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::Tui2, + key: "tui2", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml new file mode 100644 index 00000000000..fececb15036 --- /dev/null +++ b/codex-rs/tui2/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "codex-tui2" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_tui2" +path = "src/lib.rs" + +[[bin]] +name = "codex-tui2" +path = "src/main.rs" + +[features] +# Keep feature surface aligned with codex-tui while tui2 delegates to it. +vt100-tests = [] +debug-logs = [] + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-arg0 = { workspace = true } +codex-common = { workspace = true } +codex-core = { workspace = true } +codex-tui = { workspace = true } diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs new file mode 100644 index 00000000000..502efa76a0f --- /dev/null +++ b/codex-rs/tui2/src/lib.rs @@ -0,0 +1,24 @@ +#![deny(clippy::print_stdout, clippy::print_stderr)] +#![deny(clippy::disallowed_methods)] + +use std::path::PathBuf; + +pub use codex_tui::AppExitInfo; +pub use codex_tui::Cli; +pub use codex_tui::update_action; + +/// Entry point for the experimental TUI v2 crate. +/// +/// Currently this is a thin shim that delegates to the existing `codex-tui` +/// implementation so behavior and rendering remain identical while the new +/// viewport is developed behind a feature toggle. +pub async fn run_main( + cli: Cli, + codex_linux_sandbox_exe: Option, +) -> std::io::Result { + #[allow(clippy::print_stdout)] // for now + { + println!("Note: You are running the experimental TUI v2 implementation."); + } + codex_tui::run_main(cli, codex_linux_sandbox_exe).await +} diff --git a/codex-rs/tui2/src/main.rs b/codex-rs/tui2/src/main.rs new file mode 100644 index 00000000000..b50d994d809 --- /dev/null +++ b/codex-rs/tui2/src/main.rs @@ -0,0 +1,32 @@ +use clap::Parser; +use codex_arg0::arg0_dispatch_or_else; +use codex_common::CliConfigOverrides; +use codex_core::protocol::FinalOutput; +use codex_tui2::Cli; +use codex_tui2::run_main; + +#[derive(Parser, Debug)] +struct TopCli { + #[clap(flatten)] + config_overrides: CliConfigOverrides, + + #[clap(flatten)] + inner: Cli, +} + +fn main() -> anyhow::Result<()> { + arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + let top_cli = TopCli::parse(); + let mut inner = top_cli.inner; + inner + .config_overrides + .raw_overrides + .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; + if !token_usage.is_zero() { + println!("{}", FinalOutput::from(token_usage)); + } + Ok(()) + }) +} diff --git a/docs/config.md b/docs/config.md index 08ff2aa349c..4e78da7ac66 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,16 +39,17 @@ web_search_request = true # allow the model to request web searches Supported features: -| Key | Default | Stage | Description | -| ----------------------------------------- | :-----: | ------------ | ---------------------------------------------------- | -| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | -| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | -| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | -| `view_image_tool` | true | Stable | Include the `view_image` tool | -| `web_search_request` | false | Stable | Allow the model to issue web searches | -| `experimental_sandbox_command_assessment` | false | Experimental | Enable model-based sandbox risk assessment | -| `ghost_commit` | false | Experimental | Create a ghost commit each turn | -| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | +| Key | Default | Stage | Description | +| ----------------------------------------- | :-----: | ------------ | ----------------------------------------------------- | +| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | +| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | +| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | +| `view_image_tool` | true | Stable | Include the `view_image` tool | +| `web_search_request` | false | Stable | Allow the model to issue web searches | +| `experimental_sandbox_command_assessment` | false | Experimental | Enable model-based sandbox risk assessment | +| `ghost_commit` | false | Experimental | Create a ghost commit each turn | +| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | +| `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation | Notes: From fa4cac1e6bcf7c4a407cb29cf65fab0b4468dd6e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 9 Dec 2025 17:37:52 -0800 Subject: [PATCH 56/94] fix: introduce AbsolutePathBuf and resolve relative paths in config.toml (#7796) This PR attempts to solve two problems by introducing a `AbsolutePathBuf` type with a special deserializer: - `AbsolutePathBuf` attempts to be a generally useful abstraction, as it ensures, by constructing, that it represents a value that is an absolute, normalized path, which is a stronger guarantee than an arbitrary `PathBuf`. - Values in `config.toml` that can be either an absolute or relative path should be resolved against the folder containing the `config.toml` in the relative path case. This PR makes this easy to support: the main cost is ensuring `AbsolutePathBufGuard` is used inside `deserialize_config_toml_with_base()`. While `AbsolutePathBufGuard` may seem slightly distasteful because it relies on thread-local storage, this seems much cleaner to me than using than my various experiments with https://docs.rs/serde/latest/serde/de/trait.DeserializeSeed.html. Further, since the `deserialize()` method from the `Deserialize` trait is not async, we do not really have to worry about the deserialization work being spread across multiple threads in a way that would interfere with `AbsolutePathBufGuard`. To start, this PR introduces the use of `AbsolutePathBuf` in `OtelTlsConfig`. Note how this simplifies `otel_provider.rs` because it no longer requires `settings.codex_home` to be threaded through. Furthermore, this sets us up better for a world where multiple `config.toml` files from different folders could be loaded and then merged together, as the absolutifying of the paths must be done against the correct parent folder. --- codex-rs/Cargo.lock | 12 ++ codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 19 +-- codex-rs/core/src/config/mod.rs | 39 ++++-- codex-rs/core/src/config/types.rs | 9 +- codex-rs/otel/Cargo.toml | 1 + codex-rs/otel/src/config.rs | 8 +- codex-rs/otel/src/otel_provider.rs | 49 +++----- codex-rs/utils/absolute-path/Cargo.toml | 17 +++ codex-rs/utils/absolute-path/src/lib.rs | 152 ++++++++++++++++++++++++ 10 files changed, 246 insertions(+), 62 deletions(-) create mode 100644 codex-rs/utils/absolute-path/Cargo.toml create mode 100644 codex-rs/utils/absolute-path/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index aa1f72b4b47..58ba4f2a96c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1157,6 +1157,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-utils-absolute-path", "codex-utils-pty", "codex-utils-readiness", "codex-utils-string", @@ -1464,6 +1465,7 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-protocol", + "codex-utils-absolute-path", "eventsource-stream", "http", "opentelemetry", @@ -1650,6 +1652,16 @@ dependencies = [ "codex-tui", ] +[[package]] +name = "codex-utils-absolute-path" +version = "0.0.0" +dependencies = [ + "path-absolutize", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-utils-cache" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index bd62c72d5f7..a2521e3bdab 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -35,6 +35,7 @@ members = [ "otel", "tui", "tui2", + "utils/absolute-path", "utils/git", "utils/cache", "utils/image", @@ -90,6 +91,7 @@ codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } codex-tui2 = { path = "tui2" } +codex-utils-absolute-path = { path = "utils/absolute-path" } codex-utils-cache = { path = "utils/cache" } codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index f24cc9bc67f..2bc281d903f 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "codex-core" -version.workspace = true edition.workspace = true license.workspace = true +name = "codex-core" +version.workspace = true [lib] doctest = false @@ -18,12 +18,12 @@ askama = { workspace = true } async-channel = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } -chrono = { workspace = true, features = ["serde"] } chardetng = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } -codex-api = { workspace = true } codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } @@ -31,14 +31,15 @@ codex-keyring-store = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } dirs = { workspace = true } dunce = { workspace = true } -env-flags = { workspace = true } encoding_rs = { workspace = true } +env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } @@ -46,8 +47,10 @@ indexmap = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } mcp-types = { workspace = true } +once_cell = { workspace = true } os_info = { workspace = true } rand = { workspace = true } +regex = { workspace = true } regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } serde = { workspace = true, features = ["derive"] } @@ -58,9 +61,6 @@ sha2 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } strum_macros = { workspace = true } -url = { workspace = true } -once_cell = { workspace = true } -regex = { workspace = true } tempfile = { workspace = true } test-case = "3.3.1" test-log = { workspace = true } @@ -84,6 +84,7 @@ toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } +url = { workspace = true } uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } wildmatch = { workspace = true } @@ -94,9 +95,9 @@ test-support = [] [target.'cfg(target_os = "linux")'.dependencies] +keyring = { workspace = true, features = ["linux-native-async-persistent"] } landlock = { workspace = true } seccompiler = { workspace = true } -keyring = { workspace = true, features = ["linux-native-async-persistent"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index df7637a3012..8db08c55a28 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -40,6 +40,7 @@ use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; use codex_rmcp_client::OAuthCredentialsStoreMode; +use codex_utils_absolute_path::AbsolutePathBufGuard; use dirs::home_dir; use dunce::canonicalize; use serde::Deserialize; @@ -299,9 +300,9 @@ impl Config { ) .await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { + let cfg = deserialize_config_toml_with_base(root_value, &codex_home).map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) + e })?; Self::load_from_base_config_with_overrides(cfg, overrides, codex_home) @@ -319,9 +320,9 @@ pub async fn load_config_as_toml_with_cli_overrides( ) .await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { + let cfg = deserialize_config_toml_with_base(root_value, codex_home).map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) + e })?; Ok(cfg) @@ -357,6 +358,18 @@ fn apply_overlays( base } +fn deserialize_config_toml_with_base( + root_value: TomlValue, + config_base_dir: &Path, +) -> std::io::Result { + // This guard ensures that any relative paths that is deserialized into an + // [AbsolutePathBuf] is resolved against `config_base_dir`. + let _guard = AbsolutePathBufGuard::new(config_base_dir); + root_value + .try_into() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) +} + pub async fn load_global_mcp_servers( codex_home: &Path, ) -> std::io::Result> { @@ -1852,10 +1865,11 @@ trust_level = "trusted" }; let root_value = load_resolved_config(codex_home.path(), Vec::new(), overrides).await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { - tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) - })?; + let cfg = + deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| { + tracing::error!("Failed to deserialize overridden config: {e}"); + e + })?; assert_eq!( cfg.mcp_oauth_credentials_store, Some(OAuthCredentialsStoreMode::Keyring), @@ -1972,10 +1986,11 @@ trust_level = "trusted" ) .await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { - tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) - })?; + let cfg = + deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| { + tracing::error!("Failed to deserialize overridden config: {e}"); + e + })?; assert_eq!(cfg.model.as_deref(), Some("managed_config")); Ok(()) diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 5e1b78aa7be..6648e288a57 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -3,13 +3,14 @@ // Note this file should generally be restricted to simple struct/enum // definitions that do not contain business logic. -use serde::Deserializer; +use codex_utils_absolute_path::AbsolutePathBuf; 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; @@ -285,9 +286,9 @@ pub enum OtelHttpProtocol { #[derive(Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub struct OtelTlsConfig { - pub ca_certificate: Option, - pub client_certificate: Option, - pub client_private_key: Option, + pub ca_certificate: Option, + pub client_certificate: Option, + pub client_private_key: Option, } /// Which OTEL exporter to use. diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index 5ed6c094985..af8b72346d6 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -21,6 +21,7 @@ otel = ["opentelemetry", "opentelemetry_sdk", "opentelemetry-otlp", "tonic"] [dependencies] chrono = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-protocol = { workspace = true } eventsource-stream = { workspace = true } opentelemetry = { workspace = true, features = ["logs"], optional = true } diff --git a/codex-rs/otel/src/config.rs b/codex-rs/otel/src/config.rs index b6336b3a5c2..652a1c97b2b 100644 --- a/codex-rs/otel/src/config.rs +++ b/codex-rs/otel/src/config.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use std::path::PathBuf; +use codex_utils_absolute_path::AbsolutePathBuf; + #[derive(Clone, Debug)] pub struct OtelSettings { pub environment: String, @@ -20,9 +22,9 @@ pub enum OtelHttpProtocol { #[derive(Clone, Debug, Default)] pub struct OtelTlsConfig { - pub ca_certificate: Option, - pub client_certificate: Option, - pub client_private_key: Option, + pub ca_certificate: Option, + pub client_certificate: Option, + pub client_private_key: Option, } #[derive(Clone, Debug)] diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs index 5495db0ad35..92b1feaa188 100644 --- a/codex-rs/otel/src/otel_provider.rs +++ b/codex-rs/otel/src/otel_provider.rs @@ -2,6 +2,7 @@ use crate::config::OtelExporter; use crate::config::OtelHttpProtocol; use crate::config::OtelSettings; use crate::config::OtelTlsConfig; +use codex_utils_absolute_path::AbsolutePathBuf; use http::Uri; use opentelemetry::KeyValue; use opentelemetry_otlp::LogExporter; @@ -25,7 +26,6 @@ use std::error::Error; use std::fs; use std::io::ErrorKind; use std::io::{self}; -use std::path::Path; use std::path::PathBuf; use std::time::Duration; use tonic::metadata::MetadataMap; @@ -85,12 +85,7 @@ impl OtelProvider { .assume_http2(true); let tls_config = match tls.as_ref() { - Some(tls) => build_grpc_tls_config( - endpoint, - base_tls_config, - tls, - settings.codex_home.as_path(), - )?, + Some(tls) => build_grpc_tls_config(endpoint, base_tls_config, tls)?, None => base_tls_config, }; @@ -123,7 +118,7 @@ impl OtelProvider { .with_headers(headers.clone()); if let Some(tls) = tls.as_ref() { - let client = build_http_client(tls, settings.codex_home.as_path())?; + let client = build_http_client(tls)?; exporter_builder = exporter_builder.with_http_client(client); } @@ -149,7 +144,6 @@ fn build_grpc_tls_config( endpoint: &str, tls_config: ClientTlsConfig, tls: &OtelTlsConfig, - codex_home: &Path, ) -> Result> { let uri: Uri = endpoint.parse()?; let host = uri.host().ok_or_else(|| { @@ -161,14 +155,14 @@ fn build_grpc_tls_config( let mut config = tls_config.domain_name(host.to_owned()); if let Some(path) = tls.ca_certificate.as_ref() { - let (pem, _) = read_bytes(codex_home, path)?; + let (pem, _) = read_bytes(path)?; config = config.ca_certificate(TonicCertificate::from_pem(pem)); } match (&tls.client_certificate, &tls.client_private_key) { (Some(cert_path), Some(key_path)) => { - let (cert_pem, _) = read_bytes(codex_home, cert_path)?; - let (key_pem, _) = read_bytes(codex_home, key_path)?; + let (cert_pem, _) = read_bytes(cert_path)?; + let (key_pem, _) = read_bytes(key_path)?; config = config.identity(TonicIdentity::from_pem(cert_pem, key_pem)); } (Some(_), None) | (None, Some(_)) => { @@ -188,24 +182,20 @@ fn build_grpc_tls_config( /// `opentelemetry_sdk` `BatchLogProcessor` spawns a dedicated OS thread that uses /// `futures_executor::block_on()` rather than tokio. When the async reqwest client's /// timeout calls `tokio::time::sleep()`, it panics with "no reactor running". -fn build_http_client( - tls: &OtelTlsConfig, - codex_home: &Path, -) -> Result> { +fn build_http_client(tls: &OtelTlsConfig) -> Result> { // Wrap in block_in_place because reqwest::blocking::Client creates its own // internal tokio runtime, which would panic if built directly from an async context. - tokio::task::block_in_place(|| build_http_client_inner(tls, codex_home)) + tokio::task::block_in_place(|| build_http_client_inner(tls)) } fn build_http_client_inner( tls: &OtelTlsConfig, - codex_home: &Path, ) -> Result> { let mut builder = reqwest::blocking::Client::builder() .timeout(resolve_otlp_timeout(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)); if let Some(path) = tls.ca_certificate.as_ref() { - let (pem, location) = read_bytes(codex_home, path)?; + let (pem, location) = read_bytes(path)?; let certificate = ReqwestCertificate::from_pem(pem.as_slice()).map_err(|error| { config_error(format!( "failed to parse certificate {}: {error}", @@ -220,8 +210,8 @@ fn build_http_client_inner( match (&tls.client_certificate, &tls.client_private_key) { (Some(cert_path), Some(key_path)) => { - let (mut cert_pem, cert_location) = read_bytes(codex_home, cert_path)?; - let (key_pem, key_location) = read_bytes(codex_home, key_path)?; + let (mut cert_pem, cert_location) = read_bytes(cert_path)?; + let (key_pem, key_location) = read_bytes(key_path)?; cert_pem.extend_from_slice(key_pem.as_slice()); let identity = ReqwestIdentity::from_pem(cert_pem.as_slice()).map_err(|error| { config_error(format!( @@ -264,25 +254,16 @@ fn read_timeout_env(var: &str) -> Option { Some(Duration::from_millis(parsed as u64)) } -fn read_bytes(base: &Path, provided: &PathBuf) -> Result<(Vec, PathBuf), Box> { - let resolved = resolve_config_path(base, provided); - match fs::read(&resolved) { - Ok(bytes) => Ok((bytes, resolved)), +fn read_bytes(path: &AbsolutePathBuf) -> Result<(Vec, PathBuf), Box> { + match fs::read(path) { + Ok(bytes) => Ok((bytes, path.to_path_buf())), Err(error) => Err(Box::new(io::Error::new( error.kind(), - format!("failed to read {}: {error}", resolved.display()), + format!("failed to read {}: {error}", path.display()), ))), } } -fn resolve_config_path(base: &Path, provided: &PathBuf) -> PathBuf { - if provided.is_absolute() { - provided.clone() - } else { - base.join(provided) - } -} - fn config_error(message: impl Into) -> Box { Box::new(io::Error::new(ErrorKind::InvalidData, message.into())) } diff --git a/codex-rs/utils/absolute-path/Cargo.toml b/codex-rs/utils/absolute-path/Cargo.toml new file mode 100644 index 00000000000..486051fcb4c --- /dev/null +++ b/codex-rs/utils/absolute-path/Cargo.toml @@ -0,0 +1,17 @@ + +[package] +name = "codex-utils-absolute-path" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +path-absolutize = { workspace = true } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +serde_json = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs new file mode 100644 index 00000000000..5ea77b2b523 --- /dev/null +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -0,0 +1,152 @@ +use path_absolutize::Absolutize; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error as SerdeError; +use std::cell::RefCell; +use std::path::Display; +use std::path::Path; +use std::path::PathBuf; + +/// A path that is guaranteed to be absolute and normalized (though it is not +/// guaranteed to be canonicalized or exist on the filesystem). +/// +/// IMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set +/// using `AbsolutePathBufGuard::new(base_path)`. If no base path is set, the +/// deserialization will fail unless the path being deserialized is already +/// absolute. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AbsolutePathBuf(PathBuf); + +impl AbsolutePathBuf { + pub fn resolve_path_against_base(path: P, base_path: B) -> std::io::Result + where + P: AsRef, + B: AsRef, + { + let absolute_path = path.as_ref().absolutize_from(base_path.as_ref())?; + Ok(Self(absolute_path.into_owned())) + } + + pub fn from_absolute_path

(path: P) -> std::io::Result + where + P: AsRef, + { + let absolute_path = path.as_ref().absolutize()?; + Ok(Self(absolute_path.into_owned())) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn into_path_buf(self) -> PathBuf { + self.0 + } + + pub fn to_path_buf(&self) -> PathBuf { + self.0.clone() + } + + pub fn display(&self) -> Display<'_> { + self.0.display() + } +} + +thread_local! { + static ABSOLUTE_PATH_BASE: RefCell> = const { RefCell::new(None) }; +} + +pub struct AbsolutePathBufGuard; + +impl AbsolutePathBufGuard { + pub fn new(base_path: &Path) -> Self { + ABSOLUTE_PATH_BASE.with(|cell| { + *cell.borrow_mut() = Some(base_path.to_path_buf()); + }); + Self + } +} + +impl Drop for AbsolutePathBufGuard { + fn drop(&mut self) { + ABSOLUTE_PATH_BASE.with(|cell| { + *cell.borrow_mut() = None; + }); + } +} + +impl<'de> Deserialize<'de> for AbsolutePathBuf { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let path = PathBuf::deserialize(deserializer)?; + ABSOLUTE_PATH_BASE.with(|cell| match cell.borrow().as_deref() { + Some(base) => { + Ok(Self::resolve_path_against_base(path, base).map_err(SerdeError::custom)?) + } + None if path.is_absolute() => { + Self::from_absolute_path(path).map_err(SerdeError::custom) + } + None => Err(SerdeError::custom( + "AbsolutePathBuf deserialized without a base path", + )), + }) + } +} + +impl AsRef for AbsolutePathBuf { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl From for PathBuf { + fn from(path: AbsolutePathBuf) -> Self { + path.into_path_buf() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn create_with_absolute_path_ignores_base_path() { + let base_dir = tempdir().expect("base dir"); + let absolute_dir = tempdir().expect("absolute dir"); + let base_path = base_dir.path(); + let absolute_path = absolute_dir.path().join("file.txt"); + let abs_path_buf = + AbsolutePathBuf::resolve_path_against_base(absolute_path.clone(), base_path) + .expect("failed to create"); + assert_eq!(abs_path_buf.as_path(), absolute_path.as_path()); + } + + #[test] + fn relative_path_is_resolved_against_base_path() { + let temp_dir = tempdir().expect("base dir"); + let base_dir = temp_dir.path(); + let abs_path_buf = AbsolutePathBuf::resolve_path_against_base("file.txt", base_dir) + .expect("failed to create"); + assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path()); + } + + #[test] + fn guard_used_in_deserialization() { + let temp_dir = tempdir().expect("base dir"); + let base_dir = temp_dir.path(); + let relative_path = "subdir/file.txt"; + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(base_dir); + serde_json::from_str::(&format!(r#""{relative_path}""#)) + .expect("failed to deserialize") + }; + assert_eq!( + abs_path_buf.as_path(), + base_dir.join(relative_path).as_path() + ); + } +} From 893f5261eb620b9fd36ec61cfcae929ceb11b1cd Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 17:43:53 -0800 Subject: [PATCH 57/94] feat: support mcp in-session login (#7751) ### Summary * Added `mcpServer/oauthLogin` in app server for supporting in session MCP server login * Added `McpServerOauthLoginParams` and `McpServerOauthLoginResponse` to support above method with response returning the auth URL for consumer to open browser or display accordingly. * Added `McpServerOauthLoginCompletedNotification` which the app server would emit on MCP server login success or failure (i.e. timeout). * Refactored rmcp-client oath_login to have the ability on starting a auth server which the codex_message_processor uses for in-session auth. --- codex-rs/Cargo.lock | 1 + .../src/protocol/common.rs | 6 + .../app-server-protocol/src/protocol/v2.rs | 31 ++ codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 126 ++++++++ codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/rmcp-client/src/lib.rs | 2 + .../rmcp-client/src/perform_oauth_login.rs | 283 ++++++++++++++---- 8 files changed, 392 insertions(+), 59 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 58ba4f2a96c..9a3cd95dfaf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -887,6 +887,7 @@ dependencies = [ "codex-file-search", "codex-login", "codex-protocol", + "codex-rmcp-client", "codex-utils-json-to-toml", "core_test_support", "mcp-types", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 28583667393..c62acc88324 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -139,6 +139,11 @@ client_request_definitions! { response: v2::ModelListResponse, }, + McpServerOauthLogin => "mcpServer/oauth/login" { + params: v2::McpServerOauthLoginParams, + response: v2::McpServerOauthLoginResponse, + }, + McpServersList => "mcpServers/list" { params: v2::ListMcpServersParams, response: v2::ListMcpServersResponse, @@ -524,6 +529,7 @@ server_notification_definitions! { CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), + McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification), AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ea70b805b0a..dbef55ed159 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -688,6 +688,26 @@ pub struct ListMcpServersResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub scopes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub timeout_secs: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginResponse { + pub authorization_url: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1467,6 +1487,17 @@ pub struct McpToolCallProgressNotification { pub message: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginCompletedNotification { + pub name: String, + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 99d5a7a1410..e4a326a2c3e 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -26,6 +26,7 @@ codex-login = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } codex-feedback = { workspace = true } +codex-rmcp-client = { workspace = true } codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 65721a698ef..0a8445055dc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -55,6 +55,9 @@ use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::McpServer; +use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; +use codex_app_server_protocol::McpServerOauthLoginParams; +use codex_app_server_protocol::McpServerOauthLoginResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; @@ -115,6 +118,7 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::types::McpServerTransportConfig; use codex_core::config_loader::load_config_as_toml; use codex_core::default_client::get_codex_user_agent; use codex_core::exec::ExecParams; @@ -147,6 +151,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::UserInput as CoreInputItem; +use codex_rmcp_client::perform_oauth_login_return_url; use codex_utils_json_to_toml::json_to_toml; use std::collections::HashMap; use std::collections::HashSet; @@ -161,6 +166,7 @@ use std::time::Duration; use tokio::select; use tokio::sync::Mutex; use tokio::sync::oneshot; +use toml::Value as TomlValue; use tracing::error; use tracing::info; use tracing::warn; @@ -198,6 +204,7 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + cli_overrides: Vec<(String, TomlValue)>, conversation_listeners: HashMap>, active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. @@ -244,6 +251,7 @@ impl CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + cli_overrides: Vec<(String, TomlValue)>, feedback: CodexFeedback, ) -> Self { Self { @@ -252,6 +260,7 @@ impl CodexMessageProcessor { outgoing, codex_linux_sandbox_exe, config, + cli_overrides, conversation_listeners: HashMap::new(), active_login: Arc::new(Mutex::new(None)), pending_interrupts: Arc::new(Mutex::new(HashMap::new())), @@ -261,6 +270,16 @@ impl CodexMessageProcessor { } } + async fn load_latest_config(&self) -> Result { + Config::load_with_cli_overrides(self.cli_overrides.clone(), ConfigOverrides::default()) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to reload config: {err}"), + data: None, + }) + } + fn review_request_from_target( target: ApiReviewTarget, ) -> Result<(ReviewRequest, String), JSONRPCErrorError> { @@ -369,6 +388,9 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } + ClientRequest::McpServerOauthLogin { request_id, params } => { + self.mcp_server_oauth_login(request_id, params).await; + } ClientRequest::McpServersList { request_id, params } => { self.list_mcp_servers(request_id, params).await; } @@ -1916,6 +1938,110 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn mcp_server_oauth_login( + &self, + request_id: RequestId, + params: McpServerOauthLoginParams, + ) { + let config = match self.load_latest_config().await { + Ok(config) => config, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + if !config.features.enabled(Feature::RmcpClient) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "OAuth login is only supported when [features].rmcp_client is true in config.toml".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let McpServerOauthLoginParams { + name, + scopes, + timeout_secs, + } = params; + + let Some(server) = config.mcp_servers.get(&name) else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("No MCP server named '{name}' found."), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + }; + + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), + _ => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "OAuth login is only supported for streamable HTTP servers." + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match perform_oauth_login_return_url( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + scopes.as_deref().unwrap_or_default(), + timeout_secs, + ) + .await + { + Ok(handle) => { + let authorization_url = handle.authorization_url().to_string(); + let notification_name = name.clone(); + let outgoing = Arc::clone(&self.outgoing); + + tokio::spawn(async move { + let (success, error) = match handle.wait().await { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + }; + + let notification = ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: notification_name, + success, + error, + }, + ); + outgoing.send_server_notification(notification).await; + }); + + let response = McpServerOauthLoginResponse { authorization_url }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to login to MCP server '{name}': {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) { let snapshot = collect_mcp_snapshot(self.config.as_ref()).await; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 90560e9b3c5..6a6cf5edb25 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -59,6 +59,7 @@ impl MessageProcessor { outgoing.clone(), codex_linux_sandbox_exe, Arc::clone(&config), + cli_overrides.clone(), feedback, ); let config_api = ConfigApi::new(config.codex_home.clone(), cli_overrides); diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index ac617f3d29c..954898cea49 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -16,7 +16,9 @@ pub use oauth::WrappedOAuthTokenResponse; pub use oauth::delete_oauth_tokens; pub(crate) use oauth::load_oauth_tokens; pub use oauth::save_oauth_tokens; +pub use perform_oauth_login::OauthLoginHandle; pub use perform_oauth_login::perform_oauth_login; +pub use perform_oauth_login::perform_oauth_login_return_url; pub use rmcp::model::ElicitationAction; pub use rmcp_client::Elicitation; pub use rmcp_client::ElicitationResponse; diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index d8ffdd3949a..9815a3a22d6 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -22,6 +22,11 @@ use crate::save_oauth_tokens; use crate::utils::apply_default_headers; use crate::utils::build_default_headers; +struct OauthHeaders { + http_headers: Option>, + env_http_headers: Option>, +} + struct CallbackServerGuard { server: Arc, } @@ -40,70 +45,52 @@ pub async fn perform_oauth_login( env_http_headers: Option>, scopes: &[String], ) -> Result<()> { - let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); - let guard = CallbackServerGuard { - server: Arc::clone(&server), + let headers = OauthHeaders { + http_headers, + env_http_headers, }; + OauthLoginFlow::new( + server_name, + server_url, + store_mode, + headers, + scopes, + true, + None, + ) + .await? + .finish() + .await +} - let redirect_uri = match server.server_addr() { - tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => { - format!("http://{}:{}/callback", addr.ip(), addr.port()) - } - tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => { - format!("http://[{}]:{}/callback", addr.ip(), addr.port()) - } - #[cfg(not(target_os = "windows"))] - _ => return Err(anyhow!("unable to determine callback address")), +pub async fn perform_oauth_login_return_url( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, + scopes: &[String], + timeout_secs: Option, +) -> Result { + let headers = OauthHeaders { + http_headers, + env_http_headers, }; + let flow = OauthLoginFlow::new( + server_name, + server_url, + store_mode, + headers, + scopes, + false, + timeout_secs, + ) + .await?; - let (tx, rx) = oneshot::channel(); - spawn_callback_server(server, tx); - - let default_headers = build_default_headers(http_headers, env_http_headers)?; - let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; - - let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; - let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); - oauth_state - .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) - .await?; - let auth_url = oauth_state.get_authorization_url().await?; - - println!("Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n"); - - if webbrowser::open(&auth_url).is_err() { - println!("(Browser launch failed; please copy the URL above manually.)"); - } - - let (code, csrf_state) = timeout(Duration::from_secs(300), rx) - .await - .context("timed out waiting for OAuth callback")? - .context("OAuth callback was cancelled")?; - - oauth_state - .handle_callback(&code, &csrf_state) - .await - .context("failed to handle OAuth callback")?; - - let (client_id, credentials_opt) = oauth_state - .get_credentials() - .await - .context("failed to retrieve OAuth credentials")?; - let credentials = - credentials_opt.ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?; - - let expires_at = compute_expires_at_millis(&credentials); - let stored = StoredOAuthTokens { - server_name: server_name.to_string(), - url: server_url.to_string(), - client_id, - token_response: WrappedOAuthTokenResponse(credentials), - expires_at, - }; - save_oauth_tokens(server_name, &stored, store_mode)?; + let authorization_url = flow.authorization_url(); + let completion = flow.spawn(); - drop(guard); - Ok(()) + Ok(OauthLoginHandle::new(authorization_url, completion)) } fn spawn_callback_server(server: Arc, tx: oneshot::Sender<(String, String)>) { @@ -160,3 +147,181 @@ fn parse_oauth_callback(path: &str) -> Option { state: state?, }) } + +pub struct OauthLoginHandle { + authorization_url: String, + completion: oneshot::Receiver>, +} + +impl OauthLoginHandle { + fn new(authorization_url: String, completion: oneshot::Receiver>) -> Self { + Self { + authorization_url, + completion, + } + } + + pub fn authorization_url(&self) -> &str { + &self.authorization_url + } + + pub fn into_parts(self) -> (String, oneshot::Receiver>) { + (self.authorization_url, self.completion) + } + + pub async fn wait(self) -> Result<()> { + self.completion + .await + .map_err(|err| anyhow!("OAuth login task was cancelled: {err}"))? + } +} + +struct OauthLoginFlow { + auth_url: String, + oauth_state: OAuthState, + rx: oneshot::Receiver<(String, String)>, + guard: CallbackServerGuard, + server_name: String, + server_url: String, + store_mode: OAuthCredentialsStoreMode, + launch_browser: bool, + timeout: Duration, +} + +impl OauthLoginFlow { + async fn new( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + headers: OauthHeaders, + scopes: &[String], + launch_browser: bool, + timeout_secs: Option, + ) -> Result { + const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300; + + let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); + let guard = CallbackServerGuard { + server: Arc::clone(&server), + }; + + let redirect_uri = match server.server_addr() { + tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => { + let ip = addr.ip(); + let port = addr.port(); + format!("http://{ip}:{port}/callback") + } + tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => { + let ip = addr.ip(); + let port = addr.port(); + format!("http://[{ip}]:{port}/callback") + } + #[cfg(not(target_os = "windows"))] + _ => return Err(anyhow!("unable to determine callback address")), + }; + + let (tx, rx) = oneshot::channel(); + spawn_callback_server(server, tx); + + let OauthHeaders { + http_headers, + env_http_headers, + } = headers; + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; + + let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + oauth_state + .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) + .await?; + let auth_url = oauth_state.get_authorization_url().await?; + let timeout_secs = timeout_secs.unwrap_or(DEFAULT_OAUTH_TIMEOUT_SECS).max(1); + let timeout = Duration::from_secs(timeout_secs as u64); + + Ok(Self { + auth_url, + oauth_state, + rx, + guard, + server_name: server_name.to_string(), + server_url: server_url.to_string(), + store_mode, + launch_browser, + timeout, + }) + } + + fn authorization_url(&self) -> String { + self.auth_url.clone() + } + + async fn finish(mut self) -> Result<()> { + if self.launch_browser { + let server_name = &self.server_name; + let auth_url = &self.auth_url; + println!( + "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" + ); + + if webbrowser::open(auth_url).is_err() { + println!("(Browser launch failed; please copy the URL above manually.)"); + } + } + + let result = async { + let (code, csrf_state) = timeout(self.timeout, &mut self.rx) + .await + .context("timed out waiting for OAuth callback")? + .context("OAuth callback was cancelled")?; + + self.oauth_state + .handle_callback(&code, &csrf_state) + .await + .context("failed to handle OAuth callback")?; + + let (client_id, credentials_opt) = self + .oauth_state + .get_credentials() + .await + .context("failed to retrieve OAuth credentials")?; + let credentials = credentials_opt + .ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?; + + let expires_at = compute_expires_at_millis(&credentials); + let stored = StoredOAuthTokens { + server_name: self.server_name.clone(), + url: self.server_url.clone(), + client_id, + token_response: WrappedOAuthTokenResponse(credentials), + expires_at, + }; + save_oauth_tokens(&self.server_name, &stored, self.store_mode)?; + + Ok(()) + } + .await; + + drop(self.guard); + result + } + + fn spawn(self) -> oneshot::Receiver> { + let server_name_for_logging = self.server_name.clone(); + let (tx, rx) = oneshot::channel(); + + tokio::spawn(async move { + let result = self.finish().await; + + if let Err(err) = &result { + eprintln!( + "Failed to complete OAuth login for '{server_name_for_logging}': {err:#}" + ); + } + + let _ = tx.send(result); + }); + + rx + } +} From 967d063f4bd50c6c7b8402c504e5c2045f3d2165 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 9 Dec 2025 18:30:16 -0800 Subject: [PATCH 58/94] parse rg | head a search (#7797) --- codex-rs/core/src/parse_command.rs | 187 +++++++++++++++++++++-------- 1 file changed, 135 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index f3353470427..399513f5ae0 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -117,9 +117,6 @@ mod tests { query: None, path: None, }, - ParsedCommand::Unknown { - cmd: "head -n 40".to_string(), - }, ], ); } @@ -143,16 +140,11 @@ mod tests { let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200"; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - ParsedCommand::Search { - cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(), - query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()), - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 200".to_string(), - }, - ], + vec![ParsedCommand::Search { + cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(), + query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()), + path: None, + }], ); } @@ -174,16 +166,11 @@ mod tests { let inner = "rg --files | head -n 50"; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - ParsedCommand::Search { - cmd: "rg --files".to_string(), - query: None, - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 50".to_string(), - }, - ], + vec![ParsedCommand::Search { + cmd: "rg --files".to_string(), + query: None, + path: None, + }], ); } @@ -273,6 +260,19 @@ mod tests { ); } + #[test] + fn supports_head_file_only() { + let inner = "head Cargo.toml"; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Read { + cmd: inner.to_string(), + name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), + }], + ); + } + #[test] fn supports_cat_sed_n() { let inner = "cat tui/Cargo.toml | sed -n '1,200p'"; @@ -313,6 +313,19 @@ mod tests { ); } + #[test] + fn supports_tail_file_only() { + let inner = "tail README.md"; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Read { + cmd: inner.to_string(), + name: "README.md".to_string(), + path: PathBuf::from("README.md"), + }], + ); + } + #[test] fn supports_npm_run_build_is_unknown() { assert_parsed( @@ -391,6 +404,19 @@ mod tests { ); } + #[test] + fn supports_single_string_script_with_cd_and_pipe() { + let inner = r#"cd /Users/pakrym/code/codex && rg -n "codex_api" codex-rs -S | head -n 50"#; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Search { + cmd: "rg -n codex_api codex-rs -S".to_string(), + query: Some("codex_api".to_string()), + path: Some("codex-rs".to_string()), + }], + ); + } + // ---- is_small_formatting_command unit tests ---- #[test] fn small_formatting_always_true_commands() { @@ -408,38 +434,43 @@ mod tests { fn head_behavior() { // No args -> small formatting assert!(is_small_formatting_command(&vec_str(&["head"]))); - // Numeric count only -> not considered small formatting by implementation - assert!(!is_small_formatting_command(&shlex_split_safe( - "head -n 40" - ))); + // Numeric count only -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("head -n 40"))); // With explicit file -> not small formatting assert!(!is_small_formatting_command(&shlex_split_safe( "head -n 40 file.txt" ))); - // File only (no count) -> treated as small formatting by implementation - assert!(is_small_formatting_command(&vec_str(&["head", "file.txt"]))); + // File only (no count) -> not formatting + assert!(!is_small_formatting_command(&vec_str(&[ + "head", "file.txt" + ]))); } #[test] fn tail_behavior() { // No args -> small formatting assert!(is_small_formatting_command(&vec_str(&["tail"]))); - // Numeric with plus offset -> not small formatting - assert!(!is_small_formatting_command(&shlex_split_safe( + // Numeric with plus offset -> formatting + assert!(is_small_formatting_command(&shlex_split_safe( "tail -n +10" ))); assert!(!is_small_formatting_command(&shlex_split_safe( "tail -n +10 file.txt" ))); - // Numeric count - assert!(!is_small_formatting_command(&shlex_split_safe( - "tail -n 30" - ))); + // Numeric count -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("tail -n 30"))); assert!(!is_small_formatting_command(&shlex_split_safe( "tail -n 30 file.txt" ))); - // File only -> small formatting by implementation - assert!(is_small_formatting_command(&vec_str(&["tail", "file.txt"]))); + // Byte count -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("tail -c 30"))); + assert!(is_small_formatting_command(&shlex_split_safe( + "tail -c +10" + ))); + // File only (no count) -> not formatting + assert!(!is_small_formatting_command(&vec_str(&[ + "tail", "file.txt" + ]))); } #[test] @@ -714,20 +745,15 @@ mod tests { #[test] fn bash_dash_c_pipeline_parsing() { - // Ensure -c is handled similarly to -lc by normalization + // Ensure -c is handled similarly to -lc by shell parsing let inner = "rg --files | head -n 1"; assert_parsed( - &shlex_split_safe(inner), - vec![ - ParsedCommand::Search { - cmd: "rg --files".to_string(), - query: None, - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 1".to_string(), - }, - ], + &vec_str(&["bash", "-c", inner]), + vec![ParsedCommand::Search { + cmd: "rg --files".to_string(), + query: None, + path: None, + }], ); } @@ -1384,13 +1410,50 @@ fn is_small_formatting_command(tokens: &[String]) -> bool { // Treat as formatting when no explicit file operand is present. // Common forms: `head -n 40`, `head -c 100`. // Keep cases like `head -n 40 file`. - tokens.len() < 3 + match tokens { + // `head` + [_] => true, + // `head ` or `head -n50`/`head -c100` + [_, arg] => arg.starts_with('-'), + // `head -n 40` / `head -c 100` (no file operand) + [_, flag, count] + if (flag == "-n" || flag == "-c") + && count.chars().all(|c| c.is_ascii_digit()) => + { + true + } + _ => false, + } } "tail" => { // Treat as formatting when no explicit file operand is present. - // Common forms: `tail -n +10`, `tail -n 30`. + // Common forms: `tail -n +10`, `tail -n 30`, `tail -c 100`. // Keep cases like `tail -n 30 file`. - tokens.len() < 3 + match tokens { + // `tail` + [_] => true, + // `tail ` or `tail -n30`/`tail -n+10` + [_, arg] => arg.starts_with('-'), + // `tail -n 30` / `tail -n +10` (no file operand) + [_, flag, count] + if flag == "-n" + && (count.chars().all(|c| c.is_ascii_digit()) + || (count.starts_with('+') + && count[1..].chars().all(|c| c.is_ascii_digit()))) => + { + true + } + // `tail -c 100` / `tail -c +10` (no file operand) + [_, flag, count] + if flag == "-c" + && (count.chars().all(|c| c.is_ascii_digit()) + || (count.starts_with('+') + && count[1..].chars().all(|c| c.is_ascii_digit()))) => + { + true + } + _ => false, + } } "sed" => { // Keep `sed -n file` (treated as a file read elsewhere); @@ -1543,6 +1606,16 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }; } } + if let [path] = tail + && !path.starts_with('-') + { + let name = short_display_path(path); + return ParsedCommand::Read { + cmd: shlex_join(main_cmd), + name, + path: PathBuf::from(path), + }; + } ParsedCommand::Unknown { cmd: shlex_join(main_cmd), } @@ -1587,6 +1660,16 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }; } } + if let [path] = tail + && !path.starts_with('-') + { + let name = short_display_path(path); + return ParsedCommand::Read { + cmd: shlex_join(main_cmd), + name, + path: PathBuf::from(path), + }; + } ParsedCommand::Unknown { cmd: shlex_join(main_cmd), } From fc4249313b723102c82e59272efcc4ad27609ae3 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 9 Dec 2025 19:00:33 -0800 Subject: [PATCH 59/94] Elevated Sandbox 1 (#7788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updating helpers, refactoring some functions that will be used in the elevated sandbox - better logging - better and faster handling of ACL checks/writes - No functional change—legacy restricted-token sandbox remains the only path. --- codex-rs/Cargo.lock | 1 + codex-rs/windows-sandbox-rs/Cargo.toml | 1 + codex-rs/windows-sandbox-rs/src/acl.rs | 342 ++++++++++++++++++--- codex-rs/windows-sandbox-rs/src/audit.rs | 180 ++--------- codex-rs/windows-sandbox-rs/src/env.rs | 34 +- codex-rs/windows-sandbox-rs/src/logging.rs | 24 +- codex-rs/windows-sandbox-rs/src/process.rs | 42 ++- codex-rs/windows-sandbox-rs/src/token.rs | 64 +++- codex-rs/windows-sandbox-rs/src/winutil.rs | 20 ++ 9 files changed, 478 insertions(+), 230 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9a3cd95dfaf..bca96ff6313 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1727,6 +1727,7 @@ name = "codex-windows-sandbox" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "codex-protocol", "dirs-next", "dunce", diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 29306371b29..1b936f05cab 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] anyhow = "1.0" +chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] } dunce = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/codex-rs/windows-sandbox-rs/src/acl.rs b/codex-rs/windows-sandbox-rs/src/acl.rs index f2e1e09480a..34d523d1f53 100644 --- a/codex-rs/windows-sandbox-rs/src/acl.rs +++ b/codex-rs/windows-sandbox-rs/src/acl.rs @@ -1,4 +1,4 @@ -use crate::winutil::to_wide; +use crate::winutil::to_wide; use anyhow::anyhow; use anyhow::Result; use std::ffi::c_void; @@ -9,6 +9,7 @@ use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::HLOCAL; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Security::AclSizeInformation; +use windows_sys::Win32::Security::Authorization::GetEffectiveRightsFromAclW; use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; use windows_sys::Win32::Security::Authorization::GetSecurityInfo; use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; @@ -21,28 +22,148 @@ use windows_sys::Win32::Security::Authorization::TRUSTEE_W; use windows_sys::Win32::Security::EqualSid; use windows_sys::Win32::Security::GetAce; use windows_sys::Win32::Security::GetAclInformation; +use windows_sys::Win32::Security::MapGenericMask; use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; use windows_sys::Win32::Security::ACE_HEADER; use windows_sys::Win32::Security::ACL; use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; +use windows_sys::Win32::Security::GENERIC_MAPPING; use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; +use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; -use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; +use windows_sys::Win32::Storage::FileSystem::READ_CONTROL; const SE_KERNEL_OBJECT: u32 = 6; const INHERIT_ONLY_ACE: u8 = 0x08; const GENERIC_WRITE_MASK: u32 = 0x4000_0000; const DENY_ACCESS: i32 = 3; +/// Fetch DACL via handle-based query; caller must LocalFree the returned SD. +pub unsafe fn fetch_dacl_handle(path: &Path) -> Result<(*mut ACL, *mut c_void)> { + let wpath = to_wide(path); + let h = CreateFileW( + wpath.as_ptr(), + READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null_mut(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + 0, + ); + if h == INVALID_HANDLE_VALUE { + return Err(anyhow!("CreateFileW failed for {}", path.display())); + } + let mut p_sd: *mut c_void = std::ptr::null_mut(); + let mut p_dacl: *mut ACL = std::ptr::null_mut(); + let code = GetSecurityInfo( + h, + 1, // SE_FILE_OBJECT + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + CloseHandle(h); + if code != ERROR_SUCCESS { + return Err(anyhow!( + "GetSecurityInfo failed for {}: {}", + path.display(), + code + )); + } + Ok((p_dacl, p_sd)) +} + +/// Fast mask-based check: does any ACE for provided SIDs grant at least one desired bit? Skips inherit-only. +pub unsafe fn dacl_quick_mask_allows( + p_dacl: *mut ACL, + psids: &[*mut c_void], + desired_mask: u32, +) -> bool { + if p_dacl.is_null() { + return false; + } + let mut info: ACL_SIZE_INFORMATION = std::mem::zeroed(); + let ok = GetAclInformation( + p_dacl as *const ACL, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ); + if ok == 0 { + return false; + } + let mapping = GENERIC_MAPPING { + GenericRead: FILE_GENERIC_READ, + GenericWrite: FILE_GENERIC_WRITE, + GenericExecute: FILE_GENERIC_EXECUTE, + GenericAll: FILE_ALL_ACCESS, + }; + for i in 0..(info.AceCount as usize) { + let mut p_ace: *mut c_void = std::ptr::null_mut(); + if GetAce(p_dacl as *const ACL, i as u32, &mut p_ace) == 0 { + continue; + } + let hdr = &*(p_ace as *const ACE_HEADER); + if hdr.AceType != 0 { + continue; // not ACCESS_ALLOWED + } + if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 { + continue; + } + let base = p_ace as usize; + let sid_ptr = + (base + std::mem::size_of::() + std::mem::size_of::()) as *mut c_void; + let mut matched = false; + for sid in psids { + if EqualSid(sid_ptr, *sid) != 0 { + matched = true; + break; + } + } + if !matched { + continue; + } + let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); + let mut mask = ace.Mask; + MapGenericMask(&mut mask, &mapping); + if (mask & desired_mask) != 0 { + return true; + } + } + false +} + +/// Path-based wrapper around the quick mask check (single DACL fetch). +pub fn path_quick_mask_allows( + path: &Path, + psids: &[*mut c_void], + desired_mask: u32, +) -> Result { + unsafe { + let (p_dacl, sd) = fetch_dacl_handle(path)?; + let has = dacl_quick_mask_allows(p_dacl, psids, desired_mask); + if !sd.is_null() { + LocalFree(sd as HLOCAL); + } + Ok(has) + } +} + pub unsafe fn dacl_has_write_allow_for_sid(p_dacl: *mut ACL, psid: *mut c_void) -> bool { if p_dacl.is_null() { return false; @@ -131,6 +252,44 @@ pub unsafe fn dacl_has_write_deny_for_sid(p_dacl: *mut ACL, psid: *mut c_void) - // This accounts for deny ACEs and ordering; falls back to a conservative per-ACE scan if the API fails. #[allow(dead_code)] pub unsafe fn dacl_effective_allows_write(p_dacl: *mut ACL, psid: *mut c_void) -> bool { + if p_dacl.is_null() { + return false; + } + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut access: u32 = 0; + let ok = GetEffectiveRightsFromAclW(p_dacl, &trustee, &mut access); + if ok == ERROR_SUCCESS { + // Map generic bits to avoid “missing” write when generic permissions are present. + let mut mapped_access = access; + if (access & GENERIC_WRITE_MASK) != 0 { + mapped_access |= FILE_GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA; + } + if (access & READ_CONTROL) != 0 { + mapped_access |= FILE_GENERIC_READ; + } + let write_bits = FILE_GENERIC_WRITE + | FILE_WRITE_DATA + | FILE_APPEND_DATA + | FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES; + return (mapped_access & write_bits) != 0; + } + // Fallback: simple allow ACE scan (already ignores inherit-only) + dacl_has_write_allow_for_sid(p_dacl, psid) +} + +#[allow(dead_code)] +pub unsafe fn dacl_effective_allows_mask( + p_dacl: *mut ACL, + psid: *mut c_void, + desired_mask: u32, +) -> bool { if p_dacl.is_null() { return false; } @@ -148,51 +307,59 @@ pub unsafe fn dacl_effective_allows_write(p_dacl: *mut ACL, psid: *mut c_void) - }; let mut access: u32 = 0; let ok = GetEffectiveRightsFromAclW(p_dacl, &trustee, &mut access); - if ok != 0 { - // Check for generic or specific write bits - let write_bits = FILE_GENERIC_WRITE - | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA - | windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA - | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA - | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; - return (access & write_bits) != 0; + if ok == ERROR_SUCCESS { + // Map generic bits to avoid “missing” when generic permissions are present. + let mut mapped_access = access; + if (access & GENERIC_WRITE_MASK) != 0 { + mapped_access |= FILE_GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA; + } + if (access & READ_CONTROL) != 0 { + mapped_access |= FILE_GENERIC_READ; + } + return (mapped_access & desired_mask) == desired_mask; } - // Fallback: simple allow ACE scan (already ignores inherit-only) - dacl_has_write_allow_for_sid(p_dacl, psid) + // Fallbacks on error: if write bits are requested, reuse the write helper; otherwise fail closed. + if (desired_mask & FILE_GENERIC_WRITE) != 0 { + return dacl_effective_allows_write(p_dacl, psid); + } + false } -pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { - let mut p_sd: *mut c_void = std::ptr::null_mut(); - let mut p_dacl: *mut ACL = std::ptr::null_mut(); - let code = GetNamedSecurityInfoW( - to_wide(path).as_ptr(), - 1, - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - &mut p_dacl, - std::ptr::null_mut(), - &mut p_sd, - ); - if code != ERROR_SUCCESS { - return Err(anyhow!("GetNamedSecurityInfoW failed: {}", code)); + +#[allow(dead_code)] +const WRITE_ALLOW_MASK: u32 = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; + +/// Ensure all provided SIDs have a write-capable allow ACE on the path. +/// Returns true if any ACE was added. +#[allow(dead_code)] +pub unsafe fn ensure_allow_write_aces(path: &Path, sids: &[*mut c_void]) -> Result { + let (p_dacl, p_sd) = fetch_dacl_handle(path)?; + let mut entries: Vec = Vec::new(); + for sid in sids { + if dacl_quick_mask_allows(p_dacl, &[*sid], WRITE_ALLOW_MASK) { + continue; + } + entries.push(EXPLICIT_ACCESS_W { + grfAccessPermissions: WRITE_ALLOW_MASK, + grfAccessMode: 2, // SET_ACCESS + grfInheritance: CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: *sid as *mut u16, + }, + }); } let mut added = false; - if !dacl_has_write_allow_for_sid(p_dacl, psid) { - let trustee = TRUSTEE_W { - pMultipleTrustee: std::ptr::null_mut(), - MultipleTrusteeOperation: 0, - TrusteeForm: TRUSTEE_IS_SID, - TrusteeType: TRUSTEE_IS_UNKNOWN, - ptstrName: psid as *mut u16, - }; - let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed(); - explicit.grfAccessPermissions = - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; - explicit.grfAccessMode = 2; // SET_ACCESS - explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; - explicit.Trustee = trustee; + if !entries.is_empty() { let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); - let code2 = SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl); + let code2 = SetEntriesInAclW( + entries.len() as u32, + entries.as_ptr(), + p_dacl, + &mut p_new_dacl, + ); if code2 == ERROR_SUCCESS { let code3 = SetNamedSecurityInfoW( to_wide(path).as_ptr() as *mut u16, @@ -205,10 +372,89 @@ pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { ); if code3 == ERROR_SUCCESS { added = true; + } else { + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); + } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Err(anyhow!("SetNamedSecurityInfoW failed: {}", code3)); } if !p_new_dacl.is_null() { LocalFree(p_new_dacl as HLOCAL); } + } else { + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Err(anyhow!("SetEntriesInAclW failed: {}", code2)); + } + } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + Ok(added) +} + +/// Adds an allow ACE granting read/write/execute to the given SID on the target path. +/// +/// # Safety +/// Caller must ensure `psid` points to a valid SID and `path` refers to an existing file or directory. +pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { + let mut p_sd: *mut c_void = std::ptr::null_mut(); + let mut p_dacl: *mut ACL = std::ptr::null_mut(); + let code = GetNamedSecurityInfoW( + to_wide(path).as_ptr(), + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + if code != ERROR_SUCCESS { + return Err(anyhow!("GetNamedSecurityInfoW failed: {}", code)); + } + // Already has write? Skip costly DACL rewrite. + if dacl_has_write_allow_for_sid(p_dacl, psid) { + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Ok(false); + } + let mut added = false; + // Always ensure write is present: if an allow ACE exists without write, add one with write+RX. + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed(); + explicit.grfAccessPermissions = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; + explicit.grfAccessMode = 2; // SET_ACCESS + explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; + explicit.Trustee = trustee; + let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); + let code2 = SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl); + if code2 == ERROR_SUCCESS { + let code3 = SetNamedSecurityInfoW( + to_wide(path).as_ptr() as *mut u16, + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + p_new_dacl, + std::ptr::null_mut(), + ); + if code3 == ERROR_SUCCESS { + added = !dacl_has_write_allow_for_sid(p_dacl, psid); + } + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); } } if !p_sd.is_null() { @@ -217,6 +463,10 @@ pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { Ok(added) } +/// Adds a deny ACE to prevent write/append/delete for the given SID on the target path. +/// +/// # Safety +/// Caller must ensure `psid` points to a valid SID and `path` refers to an existing file or directory. pub unsafe fn add_deny_write_ace(path: &Path, psid: *mut c_void) -> Result { let mut p_sd: *mut c_void = std::ptr::null_mut(); let mut p_dacl: *mut ACL = std::ptr::null_mut(); @@ -330,6 +580,10 @@ pub unsafe fn revoke_ace(path: &Path, psid: *mut c_void) { } } +/// Grants RX to the null device for the given SID to support stdout/stderr redirection. +/// +/// # Safety +/// Caller must ensure `psid` is a valid SID pointer. pub unsafe fn allow_null_device(psid: *mut c_void) { let desired = 0x00020000 | 0x00040000; // READ_CONTROL | WRITE_DAC let h = CreateFileW( diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 6234dbf26af..7e35bf7517b 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -1,12 +1,12 @@ use crate::acl::add_deny_write_ace; +use crate::acl::path_quick_mask_allows; use crate::cap::cap_sid_file; use crate::cap::load_or_create_cap_sids; -use crate::logging::log_note; +use crate::logging::{debug_log, log_note}; use crate::policy::SandboxPolicy; use crate::token::convert_string_sid_to_sid; use crate::token::world_sid; use anyhow::anyhow; -use crate::winutil::to_wide; use anyhow::Result; use std::collections::HashSet; use std::ffi::c_void; @@ -14,38 +14,10 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; -use windows_sys::Win32::Foundation::CloseHandle; -use windows_sys::Win32::Foundation::ERROR_SUCCESS; -use windows_sys::Win32::Foundation::HLOCAL; -use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; -use windows_sys::Win32::Foundation::LocalFree; -use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; -use windows_sys::Win32::Security::ACE_HEADER; -use windows_sys::Win32::Security::ACL; -use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; -use windows_sys::Win32::Security::AclSizeInformation; -use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; -use windows_sys::Win32::Security::Authorization::GetSecurityInfo; -use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; -use windows_sys::Win32::Security::EqualSid; -use windows_sys::Win32::Security::GetAce; -use windows_sys::Win32::Security::GetAclInformation; -use windows_sys::Win32::Security::MapGenericMask; -use windows_sys::Win32::Security::GENERIC_MAPPING; -use windows_sys::Win32::Storage::FileSystem::CreateFileW; -use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; -use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; -use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; // Preflight scan limits const MAX_ITEMS_PER_DIR: i32 = 1000; @@ -109,79 +81,10 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap } unsafe fn path_has_world_write_allow(path: &Path) -> Result { - // Prefer handle-based query (often faster than name-based), fallback to name-based on error - let mut p_sd: *mut c_void = std::ptr::null_mut(); - let mut p_dacl: *mut ACL = std::ptr::null_mut(); - - let mut try_named = false; - let wpath = to_wide(path); - let h = CreateFileW( - wpath.as_ptr(), - 0x00020000, // READ_CONTROL - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS, - 0, - ); - if h == INVALID_HANDLE_VALUE { - try_named = true; - } else { - let code = GetSecurityInfo( - h, - 1, // SE_FILE_OBJECT - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - &mut p_dacl, - std::ptr::null_mut(), - &mut p_sd, - ); - CloseHandle(h); - if code != ERROR_SUCCESS { - try_named = true; - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - p_sd = std::ptr::null_mut(); - p_dacl = std::ptr::null_mut(); - } - } - } - - if try_named { - let code = GetNamedSecurityInfoW( - wpath.as_ptr(), - 1, - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - &mut p_dacl, - std::ptr::null_mut(), - &mut p_sd, - ); - if code != ERROR_SUCCESS { - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - } - return Ok(false); - } - } - let mut world = world_sid()?; let psid_world = world.as_mut_ptr() as *mut c_void; - // Very fast mask-based check for world-writable grants (includes GENERIC_*). - if !dacl_quick_world_write_mask_allows(p_dacl, psid_world) { - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - } - return Ok(false); - } - // Quick detector flagged a write grant for Everyone: treat as writable. - let has = true; - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - } - Ok(has) + let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES; + path_quick_mask_allows(path, &[psid_world], write_mask) } pub fn audit_everyone_writable( @@ -193,6 +96,21 @@ pub fn audit_everyone_writable( let mut flagged: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); let mut checked = 0usize; + let check_world_writable = |path: &Path| -> bool { + match unsafe { path_has_world_write_allow(path) } { + Ok(has) => has, + Err(err) => { + debug_log( + &format!( + "AUDIT: treating unreadable ACL as not world-writable: {} ({err})", + path.display() + ), + logs_base_dir, + ); + false + } + } + }; // Fast path: check CWD immediate children first so workspace issues are caught early. if let Ok(read) = std::fs::read_dir(cwd) { for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) { @@ -210,7 +128,7 @@ pub fn audit_everyone_writable( } let p = ent.path(); checked += 1; - let has = unsafe { path_has_world_write_allow(&p)? }; + let has = check_world_writable(&p); if has { let key = normalize_path_key(&p); if seen.insert(key) { @@ -228,7 +146,7 @@ pub fn audit_everyone_writable( break; } checked += 1; - let has_root = unsafe { path_has_world_write_allow(&root)? }; + let has_root = check_world_writable(&root); if has_root { let key = normalize_path_key(&root); if seen.insert(key) { @@ -260,7 +178,7 @@ pub fn audit_everyone_writable( } if ft.is_dir() { checked += 1; - let has_child = unsafe { path_has_world_write_allow(&p)? }; + let has_child = check_world_writable(&p); if has_child { let key = normalize_path_key(&p); if seen.insert(key) { @@ -384,57 +302,3 @@ pub fn apply_capability_denies_for_world_writable( } Ok(()) } -// Fast mask-based check: does the DACL contain any ACCESS_ALLOWED ACE for -// Everyone that grants write after generic bits are expanded? Skips inherit-only -// ACEs (do not apply to the current object). -unsafe fn dacl_quick_world_write_mask_allows(p_dacl: *mut ACL, psid_world: *mut c_void) -> bool { - if p_dacl.is_null() { - return false; - } - const INHERIT_ONLY_ACE: u8 = 0x08; - let mut info: ACL_SIZE_INFORMATION = std::mem::zeroed(); - let ok = GetAclInformation( - p_dacl as *const ACL, - &mut info as *mut _ as *mut c_void, - std::mem::size_of::() as u32, - AclSizeInformation, - ); - if ok == 0 { - return false; - } - let mapping = GENERIC_MAPPING { - GenericRead: FILE_GENERIC_READ, - GenericWrite: FILE_GENERIC_WRITE, - GenericExecute: FILE_GENERIC_EXECUTE, - GenericAll: FILE_ALL_ACCESS, - }; - for i in 0..(info.AceCount as usize) { - let mut p_ace: *mut c_void = std::ptr::null_mut(); - if GetAce(p_dacl as *const ACL, i as u32, &mut p_ace) == 0 { - continue; - } - let hdr = &*(p_ace as *const ACE_HEADER); - if hdr.AceType != 0 { - // ACCESS_ALLOWED_ACE_TYPE - continue; - } - if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 { - continue; - } - let base = p_ace as usize; - let sid_ptr = - (base + std::mem::size_of::() + std::mem::size_of::()) as *mut c_void; // skip header + mask - if EqualSid(sid_ptr, psid_world) == 0 { - continue; - } - let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); - let mut mask = ace.Mask; - // Expand generic bits to concrete file rights before checking for write. - MapGenericMask(&mut mask, &mapping); - let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES; - if (mask & write_mask) != 0 { - return true; - } - } - false -} diff --git a/codex-rs/windows-sandbox-rs/src/env.rs b/codex-rs/windows-sandbox-rs/src/env.rs index a8a3cda71da..65950a0d595 100644 --- a/codex-rs/windows-sandbox-rs/src/env.rs +++ b/codex-rs/windows-sandbox-rs/src/env.rs @@ -1,11 +1,10 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; +use dirs_next::home_dir; use std::collections::HashMap; use std::env; -use std::fs::File; -use std::fs::{self}; +use std::fs::{self, File}; use std::io::Write; -use std::path::Path; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub fn normalize_null_device_env(env_map: &mut HashMap) { let keys: Vec = env_map.keys().cloned().collect(); @@ -29,6 +28,21 @@ pub fn ensure_non_interactive_pager(env_map: &mut HashMap) { env_map.entry("LESS".into()).or_insert_with(|| "".into()); } +// Keep PATH and PATHEXT stable for callers that rely on inheriting the parent process env. +#[allow(dead_code)] +pub fn inherit_path_env(env_map: &mut HashMap) { + if !env_map.contains_key("PATH") { + if let Ok(path) = env::var("PATH") { + env_map.insert("PATH".into(), path); + } + } + if !env_map.contains_key("PATHEXT") { + if let Ok(pathext) = env::var("PATHEXT") { + env_map.insert("PATHEXT".into(), pathext); + } + } +} + fn prepend_path(env_map: &mut HashMap, prefix: &str) { let existing = env_map .get("PATH") @@ -64,7 +78,7 @@ fn reorder_pathext_for_stubs(env_map: &mut HashMap) { .map(|s| s.to_string()) .collect(); let exts_norm: Vec = exts.iter().map(|e| e.to_ascii_uppercase()).collect(); - let want = [".BAT", ".CMD"]; // move to front if present + let want = [".BAT", ".CMD"]; let mut front: Vec = Vec::new(); for w in want { if let Some(idx) = exts_norm.iter().position(|e| e == w) { @@ -90,7 +104,7 @@ fn ensure_denybin(tools: &[&str], denybin_dir: Option<&Path>) -> Result let base = match denybin_dir { Some(p) => p.to_path_buf(), None => { - let home = dirs_next::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?; + let home = home_dir().ok_or_else(|| anyhow!("no home dir"))?; home.join(".sbx-denybin") } }; @@ -146,16 +160,12 @@ pub fn apply_no_network_to_env(env_map: &mut HashMap) -> Result< .entry("GIT_ALLOW_PROTOCOLS".into()) .or_insert_with(|| "".into()); - // Block interactive network tools that bypass HTTP(S) proxy settings, but - // allow curl/wget to run so commands like `curl --version` still succeed. - // Network access is disabled via proxy envs above. let base = ensure_denybin(&["ssh", "scp"], None)?; - // Clean up any stale stubs from previous runs so real curl/wget can run. for tool in ["curl", "wget"] { for ext in [".bat", ".cmd"] { let p = base.join(format!("{}{}", tool, ext)); if p.exists() { - let _ = std::fs::remove_file(&p); + let _ = fs::remove_file(&p); } } } diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index 9887ce81d24..2e4de1d29a6 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -2,9 +2,20 @@ use std::fs::OpenOptions; use std::io::Write; use std::path::Path; use std::path::PathBuf; +use std::sync::OnceLock; const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; -pub const LOG_FILE_NAME: &str = "sandbox_commands.rust.log"; +pub const LOG_FILE_NAME: &str = "sandbox.log"; + +fn exe_label() -> &'static str { + static LABEL: OnceLock = OnceLock::new(); + LABEL.get_or_init(|| { + std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_else(|| "proc".to_string()) + }) +} fn preview(command: &[String]) -> String { let joined = command.join(" "); @@ -35,17 +46,17 @@ fn append_line(line: &str, base_dir: Option<&Path>) { pub fn log_start(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("START: {p}"), base_dir); + log_note(&format!("START: {p}"), base_dir); } pub fn log_success(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("SUCCESS: {p}"), base_dir); + log_note(&format!("SUCCESS: {p}"), base_dir); } pub fn log_failure(command: &[String], detail: &str, base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("FAILURE: {p} ({detail})"), base_dir); + log_note(&format!("FAILURE: {p} ({detail})"), base_dir); } // Debug logging helper. Emits only when SBX_DEBUG=1 to avoid noisy logs. @@ -56,7 +67,8 @@ pub fn debug_log(msg: &str, base_dir: Option<&Path>) { } } -// Unconditional note logging to sandbox_commands.rust.log +// Unconditional note logging to sandbox.log pub fn log_note(msg: &str, base_dir: Option<&Path>) { - append_line(msg, base_dir); + let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir); } diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs index 095dcf8b983..9f73e5d0d43 100644 --- a/codex-rs/windows-sandbox-rs/src/process.rs +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -79,6 +79,7 @@ fn quote_arg(a: &str) -> String { out.push('"'); out } +#[allow(dead_code)] unsafe fn ensure_inheritable_stdio(si: &mut STARTUPINFOW) -> Result<()> { for kind in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { let h = GetStdHandle(kind); @@ -96,12 +97,16 @@ unsafe fn ensure_inheritable_stdio(si: &mut STARTUPINFOW) -> Result<()> { Ok(()) } +/// # Safety +/// Caller must provide a valid primary token handle (`h_token`) with appropriate access, +/// and the `argv`, `cwd`, and `env_map` must remain valid for the duration of the call. pub unsafe fn create_process_as_user( h_token: HANDLE, argv: &[String], cwd: &Path, env_map: &HashMap, logs_base_dir: Option<&Path>, + stdio: Option<(HANDLE, HANDLE, HANDLE)>, ) -> Result<(PROCESS_INFORMATION, STARTUPINFOW)> { let cmdline_str = argv .iter() @@ -117,19 +122,41 @@ pub unsafe fn create_process_as_user( // Point explicitly at the interactive desktop. let desktop = to_wide("Winsta0\\Default"); si.lpDesktop = desktop.as_ptr() as *mut u16; - ensure_inheritable_stdio(&mut si)?; let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); + // Ensure handles are inheritable when custom stdio is supplied. + let inherit_handles = match stdio { + Some((stdin_h, stdout_h, stderr_h)) => { + si.dwFlags |= STARTF_USESTDHANDLES; + si.hStdInput = stdin_h; + si.hStdOutput = stdout_h; + si.hStdError = stderr_h; + for h in [stdin_h, stdout_h, stderr_h] { + if SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { + return Err(anyhow!( + "SetHandleInformation failed for stdio handle: {}", + GetLastError() + )); + } + } + true + } + None => { + ensure_inheritable_stdio(&mut si)?; + true + } + }; + let ok = CreateProcessAsUserW( h_token, std::ptr::null(), cmdline.as_mut_ptr(), std::ptr::null_mut(), std::ptr::null_mut(), - 1, + inherit_handles as i32, CREATE_UNICODE_ENVIRONMENT, env_block.as_ptr() as *mut c_void, to_wide(cwd).as_ptr(), - &si, + &mut si, &mut pi, ); if ok == 0 { @@ -149,6 +176,9 @@ pub unsafe fn create_process_as_user( Ok((pi, si)) } +/// # Safety +/// Caller must provide valid process information handles. +#[allow(dead_code)] pub unsafe fn wait_process_and_exitcode(pi: &PROCESS_INFORMATION) -> Result { let res = WaitForSingleObject(pi.hProcess, INFINITE); if res != 0 { @@ -161,6 +191,9 @@ pub unsafe fn wait_process_and_exitcode(pi: &PROCESS_INFORMATION) -> Result Ok(code as i32) } +/// # Safety +/// Caller must close the returned job handle. +#[allow(dead_code)] pub unsafe fn create_job_kill_on_close() -> Result { let h = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()); if h == 0 { @@ -183,6 +216,9 @@ pub unsafe fn create_job_kill_on_close() -> Result { Ok(h) } +/// # Safety +/// Caller must pass valid handles for a job object and a process. +#[allow(dead_code)] pub unsafe fn assign_to_job(h_job: HANDLE, h_process: HANDLE) -> Result<()> { if AssignProcessToJobObject(h_job, h_process) == 0 { return Err(anyhow!( diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index 60eae9377f5..7e565bc67a2 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -24,6 +24,7 @@ use windows_sys::Win32::Security::TOKEN_DUPLICATE; use windows_sys::Win32::Security::TOKEN_PRIVILEGES; use windows_sys::Win32::Security::TOKEN_QUERY; use windows_sys::Win32::System::Threading::GetCurrentProcess; +use windows_sys::Win32::System::Threading::OpenProcessToken; const DISABLE_MAX_PRIVILEGE: u32 = 0x01; const LUA_TOKEN: u32 = 0x04; @@ -52,6 +53,8 @@ pub unsafe fn world_sid() -> Result> { Ok(buf) } +/// # Safety +/// Caller is responsible for freeing the returned SID with `LocalFree`. pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { #[link(name = "advapi32")] extern "system" { @@ -66,6 +69,9 @@ pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { } } +/// # Safety +/// Caller must close the returned token handle. +#[allow(dead_code)] pub unsafe fn get_current_token_for_restriction() -> Result { let desired = TOKEN_DUPLICATE | TOKEN_QUERY @@ -197,13 +203,55 @@ unsafe fn enable_single_privilege(h_token: HANDLE, name: &str) -> Result<()> { Ok(()) } -// removed unused create_write_restricted_token_strict +/// # Safety +/// Opens the current process token and adjusts privileges; caller should ensure this is needed in the current context. +#[allow(dead_code)] +pub unsafe fn enable_privilege_on_current(name: &str) -> Result<()> { + let mut h: HANDLE = 0; + let ok = OpenProcessToken( + GetCurrentProcess(), + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, + &mut h, + ); + if ok == 0 { + return Err(anyhow!("OpenProcessToken failed: {}", GetLastError())); + } + let res = enable_single_privilege(h, name); + CloseHandle(h); + res +} +/// # Safety +/// Caller must close the returned token handle. +#[allow(dead_code)] pub unsafe fn create_workspace_write_token_with_cap( psid_capability: *mut c_void, ) -> Result<(HANDLE, *mut c_void)> { let base = get_current_token_for_restriction()?; - let mut logon_sid_bytes = get_logon_sid_bytes(base)?; + let res = create_workspace_write_token_with_cap_from(base, psid_capability); + CloseHandle(base); + res +} + +/// # Safety +/// Caller must close the returned token handle. +#[allow(dead_code)] +pub unsafe fn create_readonly_token_with_cap( + psid_capability: *mut c_void, +) -> Result<(HANDLE, *mut c_void)> { + let base = get_current_token_for_restriction()?; + let res = create_readonly_token_with_cap_from(base, psid_capability); + CloseHandle(base); + res +} + +/// # Safety +/// Caller must close the returned token handle; base_token must be a valid primary token. +pub unsafe fn create_workspace_write_token_with_cap_from( + base_token: HANDLE, + psid_capability: *mut c_void, +) -> Result<(HANDLE, *mut c_void)> { + let mut logon_sid_bytes = get_logon_sid_bytes(base_token)?; let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; let mut everyone = world_sid()?; let psid_everyone = everyone.as_mut_ptr() as *mut c_void; @@ -218,7 +266,7 @@ pub unsafe fn create_workspace_write_token_with_cap( let mut new_token: HANDLE = 0; let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; let ok = CreateRestrictedToken( - base, + base_token, flags, 0, std::ptr::null(), @@ -235,11 +283,13 @@ pub unsafe fn create_workspace_write_token_with_cap( Ok((new_token, psid_capability)) } -pub unsafe fn create_readonly_token_with_cap( +/// # Safety +/// Caller must close the returned token handle; base_token must be a valid primary token. +pub unsafe fn create_readonly_token_with_cap_from( + base_token: HANDLE, psid_capability: *mut c_void, ) -> Result<(HANDLE, *mut c_void)> { - let base = get_current_token_for_restriction()?; - let mut logon_sid_bytes = get_logon_sid_bytes(base)?; + let mut logon_sid_bytes = get_logon_sid_bytes(base_token)?; let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; let mut everyone = world_sid()?; let psid_everyone = everyone.as_mut_ptr() as *mut c_void; @@ -254,7 +304,7 @@ pub unsafe fn create_readonly_token_with_cap( let mut new_token: HANDLE = 0; let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; let ok = CreateRestrictedToken( - base, + base_token, flags, 0, std::ptr::null(), diff --git a/codex-rs/windows-sandbox-rs/src/winutil.rs b/codex-rs/windows-sandbox-rs/src/winutil.rs index 5e74ce072e0..a8195612610 100644 --- a/codex-rs/windows-sandbox-rs/src/winutil.rs +++ b/codex-rs/windows-sandbox-rs/src/winutil.rs @@ -6,6 +6,7 @@ use windows_sys::Win32::System::Diagnostics::Debug::FormatMessageW; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_ALLOCATE_BUFFER; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_FROM_SYSTEM; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_IGNORE_INSERTS; +use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; pub fn to_wide>(s: S) -> Vec { let mut v: Vec = s.as_ref().encode_wide().collect(); @@ -41,3 +42,22 @@ pub fn format_last_error(err: i32) -> String { s } } + +#[allow(dead_code)] +pub fn string_from_sid_bytes(sid: &[u8]) -> Result { + unsafe { + let mut str_ptr: *mut u16 = std::ptr::null_mut(); + let ok = ConvertSidToStringSidW(sid.as_ptr() as *mut std::ffi::c_void, &mut str_ptr); + if ok == 0 || str_ptr.is_null() { + return Err(format!("ConvertSidToStringSidW failed: {}", std::io::Error::last_os_error())); + } + let mut len = 0; + while *str_ptr.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(str_ptr, len); + let out = String::from_utf16_lossy(slice); + let _ = LocalFree(str_ptr as HLOCAL); + Ok(out) + } +} From 42e081739877ba00528b99090cb63200efc334ce Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 19:31:46 -0800 Subject: [PATCH 60/94] Revert "Revert "feat: windows codesign with Azure trusted signing"" (#7757) Reverts openai/codex#7753 Updated the tag ref matching at https://github.com/openai/openai/pull/594858 so that release with tag change can be picked up correctly. --- .github/actions/windows-code-sign/action.yml | 54 ++++++++++++++++++++ .github/workflows/rust-release.yml | 12 +++++ 2 files changed, 66 insertions(+) create mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml new file mode 100644 index 00000000000..17a4fbf9995 --- /dev/null +++ b/.github/actions/windows-code-sign/action.yml @@ -0,0 +1,54 @@ +name: windows-code-sign +description: Sign Windows binaries with Azure Trusted Signing. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + client-id: + description: Azure Trusted Signing client ID. + required: true + tenant-id: + description: Azure tenant ID for Trusted Signing. + required: true + subscription-id: + description: Azure subscription ID for Trusted Signing. + required: true + endpoint: + description: Azure Trusted Signing endpoint. + required: true + account-name: + description: Azure Trusted Signing account name. + required: true + certificate-profile-name: + description: Certificate profile name for signing. + required: true + +runs: + using: composite + steps: + - name: Azure login for Trusted Signing (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Sign Windows binaries with Azure Trusted Signing + uses: azure/trusted-signing-action@v0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + files: | + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c3e9eeef9a3..b90f0027fa3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,6 +110,18 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ contains(matrix.target, 'windows') }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From f11520f5f1250f971917619dd1787fb7dc5533fc Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 20:19:37 -0800 Subject: [PATCH 61/94] Revert "feat: windows codesign with Azure trusted signing" (#7804) Reverts openai/codex#7757 --- .github/actions/windows-code-sign/action.yml | 54 -------------------- .github/workflows/rust-release.yml | 12 ----- 2 files changed, 66 deletions(-) delete mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml deleted file mode 100644 index 17a4fbf9995..00000000000 --- a/.github/actions/windows-code-sign/action.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: windows-code-sign -description: Sign Windows binaries with Azure Trusted Signing. -inputs: - target: - description: Target triple for the artifacts to sign. - required: true - client-id: - description: Azure Trusted Signing client ID. - required: true - tenant-id: - description: Azure tenant ID for Trusted Signing. - required: true - subscription-id: - description: Azure subscription ID for Trusted Signing. - required: true - endpoint: - description: Azure Trusted Signing endpoint. - required: true - account-name: - description: Azure Trusted Signing account name. - required: true - certificate-profile-name: - description: Certificate profile name for signing. - required: true - -runs: - using: composite - steps: - - name: Azure login for Trusted Signing (OIDC) - uses: azure/login@v2 - with: - client-id: ${{ inputs.client-id }} - tenant-id: ${{ inputs.tenant-id }} - subscription-id: ${{ inputs.subscription-id }} - - - name: Sign Windows binaries with Azure Trusted Signing - uses: azure/trusted-signing-action@v0 - with: - endpoint: ${{ inputs.endpoint }} - trusted-signing-account-name: ${{ inputs.account-name }} - certificate-profile-name: ${{ inputs.certificate-profile-name }} - exclude-environment-credential: true - exclude-workload-identity-credential: true - exclude-managed-identity-credential: true - exclude-shared-token-cache-credential: true - exclude-visual-studio-credential: true - exclude-visual-studio-code-credential: true - exclude-azure-cli-credential: false - exclude-azure-powershell-credential: true - exclude-azure-developer-cli-credential: true - exclude-interactive-browser-credential: true - files: | - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index b90f0027fa3..c3e9eeef9a3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,18 +110,6 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - - if: ${{ contains(matrix.target, 'windows') }} - name: Sign Windows binaries with Azure Trusted Signing - uses: ./.github/actions/windows-code-sign - with: - target: ${{ matrix.target }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From ab9ddcd50bd8a1d6eeb1c721313c889736c2f844 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 20:42:00 -0800 Subject: [PATCH 62/94] Revert "Revert "feat: windows codesign with Azure trusted signing"" (#7806) Reverts openai/codex#7804 --- .github/actions/windows-code-sign/action.yml | 54 ++++++++++++++++++++ .github/workflows/rust-release.yml | 12 +++++ 2 files changed, 66 insertions(+) create mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml new file mode 100644 index 00000000000..17a4fbf9995 --- /dev/null +++ b/.github/actions/windows-code-sign/action.yml @@ -0,0 +1,54 @@ +name: windows-code-sign +description: Sign Windows binaries with Azure Trusted Signing. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + client-id: + description: Azure Trusted Signing client ID. + required: true + tenant-id: + description: Azure tenant ID for Trusted Signing. + required: true + subscription-id: + description: Azure subscription ID for Trusted Signing. + required: true + endpoint: + description: Azure Trusted Signing endpoint. + required: true + account-name: + description: Azure Trusted Signing account name. + required: true + certificate-profile-name: + description: Certificate profile name for signing. + required: true + +runs: + using: composite + steps: + - name: Azure login for Trusted Signing (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Sign Windows binaries with Azure Trusted Signing + uses: azure/trusted-signing-action@v0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + files: | + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c3e9eeef9a3..b90f0027fa3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,6 +110,18 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ contains(matrix.target, 'windows') }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From 6fa24d65f5a2d9b3880d7a0e2c5100ba8a55d498 Mon Sep 17 00:00:00 2001 From: Gav Verma Date: Tue, 9 Dec 2025 21:17:57 -0800 Subject: [PATCH 63/94] Express rate limit warning as % remaining (#7795) image Earlier, the warning was expressed as consumed% whereas status was expressed as remaining%. This change brings the two into sync to minimize confusion and improve visual consistency. --- codex-rs/tui/src/chatwidget.rs | 6 ++++-- codex-rs/tui/src/chatwidget/tests.rs | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a0b42ddbe4d..1d5ad24a033 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -199,8 +199,9 @@ impl RateLimitWarningState { let limit_label = secondary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "weekly".to_string()); + let remaining_percent = 100.0 - threshold; warnings.push(format!( - "Heads up, you've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } @@ -217,8 +218,9 @@ impl RateLimitWarningState { let limit_label = primary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "5h".to_string()); + let remaining_percent = 100.0 - threshold; warnings.push(format!( - "Heads up, you've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0135abff733..5cc5321f37f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -515,16 +515,16 @@ fn rate_limit_warnings_emit_thresholds() { warnings, vec![ String::from( - "Heads up, you've used over 75% of your 5h limit. Run /status for a breakdown." + "Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown." ), String::from( - "Heads up, you've used over 75% of your weekly limit. Run /status for a breakdown.", + "Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.", ), String::from( - "Heads up, you've used over 95% of your 5h limit. Run /status for a breakdown." + "Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown." ), String::from( - "Heads up, you've used over 95% of your weekly limit. Run /status for a breakdown.", + "Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.", ), ], "expected one warning per limit for the highest crossed threshold" @@ -540,7 +540,7 @@ fn test_rate_limit_warnings_monthly() { assert_eq!( warnings, vec![String::from( - "Heads up, you've used over 75% of your monthly limit. Run /status for a breakdown.", + "Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.", ),], "expected one warning per limit for the highest crossed threshold" ); From d1c5db579674306c136e0f3e1f751e3510fe553b Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 22:14:14 -0800 Subject: [PATCH 64/94] chore: disable trusted signing pkg cache hit (#7807) --- .github/actions/windows-code-sign/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml index 17a4fbf9995..2be64efc98b 100644 --- a/.github/actions/windows-code-sign/action.yml +++ b/.github/actions/windows-code-sign/action.yml @@ -49,6 +49,7 @@ runs: exclude-azure-powershell-credential: true exclude-azure-developer-cli-credential: true exclude-interactive-browser-credential: true + cache-dependencies: false files: | ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe From 0ad54982ae9423965e83d78caff495d3c30247ad Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 10:30:38 +0000 Subject: [PATCH 65/94] chore: rework unified exec events (#7775) --- .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 11 + codex-rs/app-server-test-client/src/main.rs | 4 + .../app-server/src/bespoke_event_handling.rs | 15 + codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/src/tools/events.rs | 3 +- .../core/src/tools/handlers/unified_exec.rs | 31 +- .../core/src/unified_exec/async_watcher.rs | 180 ++++++++++ codex-rs/core/src/unified_exec/mod.rs | 44 ++- codex-rs/core/src/unified_exec/session.rs | 43 ++- .../core/src/unified_exec/session_manager.rs | 163 ++++----- codex-rs/core/tests/suite/unified_exec.rs | 332 ++++++++++++++++-- .../src/event_processor_with_human_output.rs | 1 + .../src/event_processor_with_jsonl_output.rs | 45 ++- .../tests/event_processor_with_json_output.rs | 89 +++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/protocol.rs | 14 + codex-rs/tui/src/chatwidget.rs | 15 +- codex-rs/tui/src/chatwidget/tests.rs | 43 +++ codex-rs/utils/pty/src/lib.rs | 10 +- 20 files changed, 874 insertions(+), 172 deletions(-) create mode 100644 codex-rs/core/src/unified_exec/async_watcher.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c62acc88324..bd9f6ddedfa 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -527,6 +527,7 @@ server_notification_definitions! { ItemCompleted => "item/completed" (v2::ItemCompletedNotification), AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), + TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index dbef55ed159..db987e27df0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1457,6 +1457,17 @@ pub struct ReasoningTextDeltaNotification { pub content_index: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TerminalInteractionNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub process_id: String, + pub stdin: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 92255cecd30..924740896e8 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -553,6 +553,10 @@ impl CodexClient { print!("{}", delta.delta); std::io::stdout().flush().ok(); } + ServerNotification::TerminalInteraction(delta) => { + println!("[stdin sent: {}]", delta.stdin); + std::io::stdout().flush().ok(); + } ServerNotification::ItemStarted(payload) => { println!("\n< item started: {:?}", payload.item); } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 2fda7bcf58f..8956aedd138 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ReasoningTextDeltaNotification; use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::TerminalInteractionNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -573,6 +574,20 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::TerminalInteraction(terminal_event) => { + let item_id = terminal_event.call_id.clone(); + + let notification = TerminalInteractionNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item_id, + process_id: terminal_event.process_id, + stdin: terminal_event.stdin, + }; + outgoing + .send_server_notification(ServerNotification::TerminalInteraction(notification)) + .await; + } EventMsg::ExecCommandEnd(exec_command_end_event) => { let ExecCommandEndEvent { call_id, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 58072f93364..fc6e4b9afd2 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -62,6 +62,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::WebSearchBegin(_) | EventMsg::WebSearchEnd(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::ExecApprovalRequest(_) diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 93bce604894..cdfc575cd9b 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -134,7 +134,6 @@ impl ToolEmitter { command: &[String], cwd: PathBuf, source: ExecCommandSource, - interaction_input: Option, process_id: Option, ) -> Self { let parsed_cmd = parse_command(command); @@ -142,7 +141,7 @@ impl ToolEmitter { command: command.to_vec(), cwd, source, - interaction_input, + interaction_input: None, // TODO(jif) drop this field in the protocol. parsed_cmd, process_id, } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c7230e54d73..abaaf4a7abe 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,9 +1,8 @@ use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecCommandSource; -use crate::protocol::ExecOutputStream; +use crate::protocol::TerminalInteractionEvent; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; @@ -189,7 +188,6 @@ impl ToolHandler for UnifiedExecHandler { &command, cwd.clone(), ExecCommandSource::UnifiedExecStartup, - None, Some(process_id.clone()), ); emitter.emit(event_ctx, ToolEventStage::Begin).await; @@ -218,7 +216,7 @@ impl ToolHandler for UnifiedExecHandler { "failed to parse write_stdin arguments: {err:?}" )) })?; - manager + let response = manager .write_stdin(WriteStdinRequest { process_id: &args.session_id.to_string(), input: &args.chars, @@ -228,7 +226,18 @@ impl ToolHandler for UnifiedExecHandler { .await .map_err(|err| { FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}")) - })? + })?; + + let interaction = TerminalInteractionEvent { + call_id: response.event_call_id.clone(), + process_id: args.session_id.to_string(), + stdin: args.chars.clone(), + }; + session + .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) + .await; + + response } other => { return Err(FunctionCallError::RespondToModel(format!( @@ -237,18 +246,6 @@ impl ToolHandler for UnifiedExecHandler { } }; - // Emit a delta event with the chunk of output we just produced, if any. - if !response.output.is_empty() { - let delta = ExecCommandOutputDeltaEvent { - call_id: response.event_call_id.clone(), - stream: ExecOutputStream::Stdout, - chunk: response.output.as_bytes().to_vec(), - }; - session - .send_event(turn.as_ref(), EventMsg::ExecCommandOutputDelta(delta)) - .await; - } - let content = format_response(&response); Ok(ToolOutput::Function { diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs new file mode 100644 index 00000000000..7412d29720f --- /dev/null +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -0,0 +1,180 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::time::Duration; +use tokio::time::Instant; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::exec::ExecToolCallOutput; +use crate::exec::StreamOutput; +use crate::protocol::EventMsg; +use crate::protocol::ExecCommandOutputDeltaEvent; +use crate::protocol::ExecCommandSource; +use crate::protocol::ExecOutputStream; +use crate::tools::events::ToolEmitter; +use crate::tools::events::ToolEventCtx; +use crate::tools::events::ToolEventStage; + +use super::CommandTranscript; +use super::UnifiedExecContext; +use super::session::UnifiedExecSession; + +/// Spawn a background task that continuously reads from the PTY, appends to the +/// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8 +/// boundaries. +pub(crate) fn start_streaming_output( + session: &UnifiedExecSession, + context: &UnifiedExecContext, + transcript: Arc>, +) { + let mut receiver = session.output_receiver(); + let session_ref = Arc::clone(&context.session); + let turn_ref = Arc::clone(&context.turn); + let call_id = context.call_id.clone(); + let cancellation_token = session.cancellation_token(); + + tokio::spawn(async move { + let mut pending: Vec = Vec::new(); + loop { + tokio::select! { + _ = cancellation_token.cancelled() => break, + result = receiver.recv() => match result { + Ok(chunk) => { + pending.extend_from_slice(&chunk); + while let Some(prefix) = split_valid_utf8_prefix(&mut pending) { + { + let mut guard = transcript.lock().await; + guard.append(&prefix); + } + + let event = ExecCommandOutputDeltaEvent { + call_id: call_id.clone(), + stream: ExecOutputStream::Stdout, + chunk: prefix, + }; + session_ref + .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) + .await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + }; + } + }); +} + +/// Spawn a background watcher that waits for the PTY to exit and then emits a +/// single ExecCommandEnd event with the aggregated transcript. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_exit_watcher( + session: Arc, + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: String, + transcript: Arc>, + started_at: Instant, +) { + let exit_token = session.cancellation_token(); + + tokio::spawn(async move { + exit_token.cancelled().await; + + let exit_code = session.exit_code().unwrap_or(-1); + let duration = Instant::now().saturating_duration_since(started_at); + emit_exec_end_for_unified_exec( + session_ref, + turn_ref, + call_id, + command, + cwd, + Some(process_id), + transcript, + String::new(), + exit_code, + duration, + ) + .await; + }); +} + +/// Emit an ExecCommandEnd event for a unified exec session, using the transcript +/// as the primary source of aggregated_output and falling back to the provided +/// text when the transcript is empty. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn emit_exec_end_for_unified_exec( + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: Option, + transcript: Arc>, + fallback_output: String, + exit_code: i32, + duration: Duration, +) { + let aggregated_output = resolve_aggregated_output(&transcript, fallback_output).await; + let output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(aggregated_output.clone()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(aggregated_output), + duration, + timed_out: false, + }; + let event_ctx = ToolEventCtx::new(session_ref.as_ref(), turn_ref.as_ref(), &call_id, None); + let emitter = ToolEmitter::unified_exec( + &command, + cwd, + ExecCommandSource::UnifiedExecStartup, + process_id, + ); + emitter + .emit(event_ctx, ToolEventStage::Success(output)) + .await; +} + +fn split_valid_utf8_prefix(buffer: &mut Vec) -> Option> { + if buffer.is_empty() { + return None; + } + + let len = buffer.len(); + let mut split = len; + while split > 0 { + if std::str::from_utf8(&buffer[..split]).is_ok() { + let prefix = buffer[..split].to_vec(); + buffer.drain(..split); + return Some(prefix); + } + + if len - split > 4 { + break; + } + split -= 1; + } + + // If no valid UTF-8 prefix was found, emit the first byte so the stream + // keeps making progress and the transcript reflects all bytes. + let byte = buffer.drain(..1).collect(); + Some(byte) +} + +async fn resolve_aggregated_output( + transcript: &Arc>, + fallback: String, +) -> String { + let guard = transcript.lock().await; + if guard.data.is_empty() { + return fallback; + } + + String::from_utf8_lossy(&guard.data).to_string() +} diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 02a0f9ead7f..0d86b69fda8 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -34,6 +34,7 @@ use tokio::sync::Mutex; use crate::codex::Session; use crate::codex::TurnContext; +mod async_watcher; mod errors; mod session; mod session_manager; @@ -51,6 +52,24 @@ pub(crate) const MAX_UNIFIED_EXEC_SESSIONS: usize = 64; // Send a warning message to the models when it reaches this number of sessions. pub(crate) const WARNING_UNIFIED_EXEC_SESSIONS: usize = 60; +#[derive(Debug, Default)] +pub(crate) struct CommandTranscript { + pub data: Vec, +} + +impl CommandTranscript { + pub fn append(&mut self, bytes: &[u8]) { + self.data.extend_from_slice(bytes); + if self.data.len() > UNIFIED_EXEC_OUTPUT_MAX_BYTES { + let excess = self + .data + .len() + .saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES); + self.data.drain(..excess); + } + } +} + pub(crate) struct UnifiedExecContext { pub session: Arc, pub turn: Arc, @@ -92,18 +111,14 @@ pub(crate) struct UnifiedExecResponse { pub chunk_id: String, pub wall_time: Duration, pub output: String, + /// Raw bytes returned for this unified exec call before any truncation. + pub raw_output: Vec, pub process_id: Option, pub exit_code: Option, pub original_token_count: Option, pub session_command: Option>, } -#[derive(Default)] -pub(crate) struct UnifiedExecSessionManager { - session_store: Mutex, -} - -// Required for mutex sharing. #[derive(Default)] pub(crate) struct SessionStore { sessions: HashMap, @@ -115,22 +130,27 @@ impl SessionStore { self.reserved_sessions_id.remove(session_id); self.sessions.remove(session_id) } +} - pub(crate) fn clear(&mut self) { - self.reserved_sessions_id.clear(); - self.sessions.clear(); +pub(crate) struct UnifiedExecSessionManager { + session_store: Mutex, +} + +impl Default for UnifiedExecSessionManager { + fn default() -> Self { + Self { + session_store: Mutex::new(SessionStore::default()), + } } } struct SessionEntry { - session: UnifiedExecSession, + session: Arc, session_ref: Arc, turn_ref: Arc, call_id: String, process_id: String, command: Vec, - cwd: PathBuf, - started_at: tokio::time::Instant, last_used: tokio::time::Instant, } diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 02465538ec3..51ebbd35696 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -98,19 +98,22 @@ impl UnifiedExecSession { let cancellation_token_clone = cancellation_token.clone(); let output_task = tokio::spawn(async move { loop { - match receiver.recv().await { - Ok(chunk) => { - let mut guard = buffer_clone.lock().await; - guard.push_chunk(chunk); - drop(guard); - notify_clone.notify_waiters(); + tokio::select! { + _ = cancellation_token_clone.cancelled() => break, + result = receiver.recv() => match result { + Ok(chunk) => { + let mut guard = buffer_clone.lock().await; + guard.push_chunk(chunk); + drop(guard); + notify_clone.notify_waiters(); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + cancellation_token_clone.cancel(); + break; + } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - cancellation_token_clone.cancel(); - break; - } - } + }; } }); @@ -136,6 +139,14 @@ impl UnifiedExecSession { } } + pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver> { + self.session.output_receiver() + } + + pub(super) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() + } + pub(super) fn has_exited(&self) -> bool { self.session.has_exited() } @@ -144,6 +155,12 @@ impl UnifiedExecSession { self.session.exit_code() } + pub(super) fn terminate(&self) { + self.session.terminate(); + self.cancellation_token.cancel(); + self.output_task.abort(); + } + async fn snapshot_output(&self) -> Vec> { let guard = self.output_buffer.lock().await; guard.snapshot() @@ -246,6 +263,6 @@ impl UnifiedExecSession { impl Drop for UnifiedExecSession { fn drop(&mut self) { - self.output_task.abort(); + self.terminate(); } } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index af706b4b238..fa64eb4bb2c 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -13,18 +13,12 @@ use tokio_util::sync::CancellationToken; use crate::bash::extract_bash_command; use crate::codex::Session; use crate::codex::TurnContext; -use crate::exec::ExecToolCallOutput; -use crate::exec::StreamOutput; use crate::exec_env::create_env; use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::protocol::BackgroundEventEvent; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandSource; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; -use crate::tools::events::ToolEmitter; -use crate::tools::events::ToolEventCtx; -use crate::tools::events::ToolEventStage; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; @@ -33,6 +27,7 @@ use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::formatted_truncate_text; +use super::CommandTranscript; use super::ExecCommandRequest; use super::MAX_UNIFIED_EXEC_SESSIONS; use super::SessionEntry; @@ -43,6 +38,9 @@ use super::UnifiedExecResponse; use super::UnifiedExecSessionManager; use super::WARNING_UNIFIED_EXEC_SESSIONS; use super::WriteStdinRequest; +use super::async_watcher::emit_exec_end_for_unified_exec; +use super::async_watcher::spawn_exit_watcher; +use super::async_watcher::start_streaming_output; use super::clamp_yield_time; use super::generate_chunk_id; use super::resolve_max_tokens; @@ -135,17 +133,23 @@ impl UnifiedExecSessionManager { .await; let session = match session { - Ok(session) => session, + Ok(session) => Arc::new(session), Err(err) => { self.release_process_id(&request.process_id).await; return Err(err); } }; + let transcript = Arc::new(tokio::sync::Mutex::new(CommandTranscript::default())); + start_streaming_output(&session, context, Arc::clone(&transcript)); + let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = clamp_yield_time(request.yield_time_ms); let start = Instant::now(); + // For the initial exec_command call, we both stream output to events + // (via start_streaming_output above) and collect a snapshot here for + // the tool response body. let OutputHandles { output_buffer, output_notify, @@ -163,36 +167,44 @@ impl UnifiedExecSessionManager { let text = String::from_utf8_lossy(&collected).to_string(); let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)); - let has_exited = session.has_exited(); let exit_code = session.exit_code(); + let has_exited = session.has_exited() || exit_code.is_some(); let chunk_id = generate_chunk_id(); let process_id = request.process_id.clone(); if has_exited { + // Short‑lived command: emit ExecCommandEnd immediately using the + // same helper as the background watcher, so all end events share + // one implementation. self.release_process_id(&request.process_id).await; let exit = exit_code.unwrap_or(-1); - Self::emit_exec_end_from_context( - context, - &request.command, + emit_exec_end_for_unified_exec( + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + request.command.clone(), cwd, + Some(process_id), + Arc::clone(&transcript), output.clone(), exit, wall_time, - // We always emit the process ID in order to keep consistency between the Begin - // event and the End event. - Some(process_id), ) .await; session.check_for_sandbox_denial_with_text(&text).await?; } else { - // Only store session if not exited. + // Long‑lived command: persist the session so write_stdin can reuse + // it, and register a background watcher that will emit + // ExecCommandEnd when the PTY eventually exits (even if no further + // tool calls are made). self.store_session( - session, + Arc::clone(&session), context, &request.command, cwd.clone(), start, process_id, + Arc::clone(&transcript), ) .await; @@ -205,6 +217,7 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id: if has_exited { None } else { @@ -238,6 +251,8 @@ impl UnifiedExecSessionManager { if !request.input.is_empty() { Self::send_input(&writer_tx, request.input.as_bytes()).await?; + // Give the remote process a brief window to react so that we are + // more likely to capture its output in the poll below. tokio::time::sleep(Duration::from_millis(100)).await; } @@ -259,16 +274,20 @@ impl UnifiedExecSessionManager { let original_token_count = approx_token_count(&text); let chunk_id = generate_chunk_id(); + // After polling, refresh_session_state tells us whether the PTY is + // still alive or has exited and been removed from the store; we thread + // that through so the handler can tag TerminalInteraction with an + // appropriate process_id and exit_code. let status = self.refresh_session_state(process_id.as_str()).await; - let (process_id, exit_code, completion_entry, event_call_id) = match status { + let (process_id, exit_code, event_call_id) = match status { SessionStatus::Alive { exit_code, call_id, process_id, - } => (Some(process_id), exit_code, None, call_id), + } => (Some(process_id), exit_code, call_id), SessionStatus::Exited { exit_code, entry } => { let call_id = entry.call_id.clone(); - (None, exit_code, Some(*entry), call_id) + (None, exit_code, call_id) } SessionStatus::Unknown => { return Err(UnifiedExecError::UnknownSessionId { @@ -282,6 +301,7 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id, exit_code, original_token_count: Some(original_token_count), @@ -292,12 +312,6 @@ impl UnifiedExecSessionManager { Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await; } - if let (Some(exit), Some(entry)) = (response.exit_code, completion_entry) { - let total_duration = Instant::now().saturating_duration_since(entry.started_at); - Self::emit_exec_end_from_entry(entry, response.output.clone(), exit, total_duration) - .await; - } - Ok(response) } @@ -371,28 +385,27 @@ impl UnifiedExecSessionManager { #[allow(clippy::too_many_arguments)] async fn store_session( &self, - session: UnifiedExecSession, + session: Arc, context: &UnifiedExecContext, command: &[String], cwd: PathBuf, started_at: Instant, process_id: String, + transcript: Arc>, ) { let entry = SessionEntry { - session, + session: Arc::clone(&session), session_ref: Arc::clone(&context.session), turn_ref: Arc::clone(&context.turn), call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), - cwd, - started_at, last_used: started_at, }; let number_sessions = { let mut store = self.session_store.lock().await; Self::prune_sessions_if_needed(&mut store); - store.sessions.insert(process_id, entry); + store.sessions.insert(process_id.clone(), entry); store.sessions.len() }; @@ -405,73 +418,18 @@ impl UnifiedExecSessionManager { ) .await; }; - } - - async fn emit_exec_end_from_entry( - entry: SessionEntry, - aggregated_output: String, - exit_code: i32, - duration: Duration, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - entry.session_ref.as_ref(), - entry.turn_ref.as_ref(), - &entry.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - &entry.command, - entry.cwd, - ExecCommandSource::UnifiedExecStartup, - None, - Some(entry.process_id.clone()), - ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; - } - async fn emit_exec_end_from_context( - context: &UnifiedExecContext, - command: &[String], - cwd: PathBuf, - aggregated_output: String, - exit_code: i32, - duration: Duration, - process_id: Option, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - context.session.as_ref(), - context.turn.as_ref(), - &context.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - command, + spawn_exit_watcher( + Arc::clone(&session), + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + command.to_vec(), cwd, - ExecCommandSource::UnifiedExecStartup, - None, process_id, + transcript, + started_at, ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; } async fn emit_waiting_status( @@ -567,7 +525,7 @@ impl UnifiedExecSessionManager { cancellation_token: &CancellationToken, deadline: Instant, ) -> Vec { - const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(25); + const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(50); let mut collected: Vec = Vec::with_capacity(4096); let mut exit_signal_received = cancellation_token.is_cancelled(); @@ -634,7 +592,9 @@ impl UnifiedExecSessionManager { .collect(); if let Some(session_id) = Self::session_id_to_prune_from_meta(&meta) { - store.remove(&session_id); + if let Some(entry) = store.remove(&session_id) { + entry.session.terminate(); + } return true; } @@ -671,8 +631,17 @@ impl UnifiedExecSessionManager { } pub(crate) async fn terminate_all_sessions(&self) { - let mut sessions = self.session_store.lock().await; - sessions.clear(); + let entries: Vec = { + let mut sessions = self.session_store.lock().await; + let entries: Vec = + sessions.sessions.drain().map(|(_, entry)| entry).collect(); + sessions.reserved_sessions_id.clear(); + entries + }; + + for entry in entries { + entry.session.terminate(); + } } } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 6a62e35dfbd..5def7aadb29 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -648,16 +648,16 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { }) .await?; - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => Some(ev.clone()), + let event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), _ => None, }) .await; - let text = String::from_utf8_lossy(&delta.chunk).to_string(); + let text = event.stdout; assert!( text.contains("HELLO-UEXEC"), - "delta chunk missing expected text: {text:?}" + "delta chunk missing expected text: {text:?}", ); wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; @@ -665,7 +665,116 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { +async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "uexec-full-lifecycle"; + let args = json!({ + "cmd": "printf 'HELLO-FULL-LIFECYCLE'", + "yield_time_ms": 50, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "finished"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "exercise full unified exec lifecycle".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut begin_event = None; + let mut end_event = None; + let mut saw_delta_with_marker = 0; + + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => begin_event = Some(ev), + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => { + let text = String::from_utf8_lossy(&ev.chunk); + if text.contains("HELLO-FULL-LIFECYCLE") { + saw_delta_with_marker += 1; + } + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => { + assert!( + end_event.is_none(), + "expected a single ExecCommandEnd event for this call id" + ); + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); + assert_eq!(begin_event.call_id, call_id); + assert!( + begin_event.process_id.is_some(), + "begin event should include a process_id for a long-lived session" + ); + + assert_eq!( + saw_delta_with_marker, 0, + "no ExecCommandOutputDelta should be sent for early exit commands" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include process_id emitted by background watcher" + ); + assert!( + end_event.aggregated_output.contains("HELLO-FULL-LIFECYCLE"), + "aggregated_output should contain the full PTY transcript; got {:?}", + end_event.aggregated_output + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); skip_if_windows!(Ok(())); @@ -740,27 +849,210 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { }) .await?; - // Expect a delta event corresponding to the write_stdin call. - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { - let text = String::from_utf8_lossy(&ev.chunk); - if text.contains("WSTDIN-MARK") { - Some(ev.clone()) - } else { - None + let mut terminal_interaction = None; + + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_interaction = Some(ev); } + EventMsg::TaskComplete(_) => break, + _ => {} } - _ => None, - }) - .await; + } + + let delta = terminal_interaction.expect("expected TerminalInteraction event"); + assert_eq!(delta.process_id, "1000"); + let expected_stdin = stdin_args + .get("chars") + .and_then(Value::as_str) + .expect("stdin chars"); + assert_eq!(delta.stdin, expected_stdin); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let open_call_id = "uexec-delayed-open"; + let open_args = json!({ + "cmd": "sleep 5 && echo MARKER1 && sleep 5 && echo MARKER2", + "yield_time_ms": 10, + }); + + // Poll stdin three times: first for no output, second after the first marker, + // and a final long poll to capture the second marker. + let first_poll_call_id = "uexec-delayed-poll-1"; + let first_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 10, + }); + + let second_poll_call_id = "uexec-delayed-poll-2"; + let second_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 6000, + }); + + let third_poll_call_id = "uexec-delayed-poll-3"; + let third_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 10000, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + open_call_id, + "exec_command", + &serde_json::to_string(&open_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + first_poll_call_id, + "write_stdin", + &serde_json::to_string(&first_poll_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call( + second_poll_call_id, + "write_stdin", + &serde_json::to_string(&second_poll_args)?, + ), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_function_call( + third_poll_call_id, + "write_stdin", + &serde_json::to_string(&third_poll_args)?, + ), + ev_completed("resp-4"), + ]), + sse(vec![ + ev_response_created("resp-5"), + ev_assistant_message("msg-1", "complete"), + ev_completed("resp-5"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "delayed terminal interaction output".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut begin_event = None; + let mut end_event = None; + let mut terminal_events = Vec::new(); + let mut delta_text = String::new(); - let text = String::from_utf8_lossy(&delta.chunk).to_string(); + // Consume all events for this turn so we can assert on each stage. + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == open_call_id => { + begin_event = Some(ev); + } + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { + delta_text.push_str(&String::from_utf8_lossy(&ev.chunk)); + } + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_events.push(ev); + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == open_call_id => { + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); assert!( - text.contains("WSTDIN-MARK"), - "stdin delta chunk missing expected text: {text:?}" + begin_event.process_id.is_some(), + "begin event should include process_id for a live session" + ); + + // We expect three terminal interactions matching the three write_stdin calls. + assert_eq!( + terminal_events.len(), + 3, + "expected three terminal interactions; got {terminal_events:?}" + ); + + for event in &terminal_events { + assert_eq!(event.call_id, open_call_id); + assert_eq!(event.process_id, "1000"); + } + assert_eq!( + terminal_events + .iter() + .map(|ev| ev.stdin.as_str()) + .collect::>(), + vec!["", "", ""], + "terminal interactions should reflect the three stdin polls" + ); + + assert!( + delta_text.contains("MARKER1") && delta_text.contains("MARKER2"), + "streamed deltas should contain both markers; got {delta_text:?}" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, open_call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include the process_id" + ); + assert!( + end_event.aggregated_output.contains("MARKER1") + && end_event.aggregated_output.contains("MARKER2"), + "aggregated output should include both markers in order; got {:?}", + end_event.aggregated_output ); - wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; Ok(()) } 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 64a5358f353..6eec8b71fcf 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -566,6 +566,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::WebSearchBegin(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 23dff015eb3..03c51662b14 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -48,6 +48,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -72,6 +73,7 @@ pub struct EventProcessorWithJsonOutput { struct RunningCommand { command: String, item_id: String, + aggregated_output: String, } #[derive(Debug, Clone)] @@ -109,6 +111,10 @@ impl EventProcessorWithJsonOutput { EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), + EventMsg::TerminalInteraction(ev) => self.handle_terminal_interaction(ev), + EventMsg::ExecCommandOutputDelta(ev) => { + self.handle_output_chunk(&ev.call_id, &ev.chunk) + } EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), @@ -172,6 +178,16 @@ impl EventProcessorWithJsonOutput { vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } + fn handle_output_chunk(&mut self, _call_id: &str, _chunk: &[u8]) -> Vec { + //TODO see how we want to process them + vec![] + } + + fn handle_terminal_interaction(&mut self, _ev: &TerminalInteractionEvent) -> Vec { + //TODO see how we want to process them + vec![] + } + fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec { let item = ThreadItem { id: self.get_next_item_id(), @@ -214,6 +230,7 @@ impl EventProcessorWithJsonOutput { RunningCommand { command: command_string.clone(), item_id: item_id.clone(), + aggregated_output: String::new(), }, ); @@ -366,7 +383,11 @@ impl EventProcessorWithJsonOutput { } fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec { - let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id) + let Some(RunningCommand { + command, + item_id, + aggregated_output, + }) = self.running_commands.remove(&ev.call_id) else { warn!( call_id = ev.call_id, @@ -379,12 +400,17 @@ impl EventProcessorWithJsonOutput { } else { CommandExecutionStatus::Failed }; + let aggregated_output = if ev.aggregated_output.is_empty() { + aggregated_output + } else { + ev.aggregated_output.clone() + }; let item = ThreadItem { id: item_id, details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command, - aggregated_output: ev.aggregated_output.clone(), + aggregated_output, exit_code: Some(ev.exit_code), status, }), @@ -455,6 +481,21 @@ impl EventProcessorWithJsonOutput { items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); } + if !self.running_commands.is_empty() { + for (_, running) in self.running_commands.drain() { + let item = ThreadItem { + id: running.item_id, + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: running.command, + aggregated_output: running.aggregated_output, + exit_code: None, + status: CommandExecutionStatus::Completed, + }), + }; + items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); + } + } + if let Some(error) = self.last_critical_error.take() { items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); } else { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 7b2d3c18ba8..2b3673f5a6d 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -48,6 +48,8 @@ use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::ExecCommandOutputDeltaEvent; +use codex_protocol::protocol::ExecOutputStream; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -699,6 +701,93 @@ fn exec_command_end_success_produces_completed_command_item() { ); } +#[test] +fn command_execution_output_delta_updates_item_progress() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo delta".to_string(), + ]; + let cwd = std::env::current_dir().unwrap(); + let parsed_cmd = Vec::new(); + + let begin = event( + "d1", + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::Agent, + interaction_input: None, + }), + ); + let out_begin = ep.collect_thread_events(&begin); + assert_eq!( + out_begin, + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })] + ); + + let delta = event( + "d2", + EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { + call_id: "delta-1".to_string(), + stream: ExecOutputStream::Stdout, + chunk: b"partial output\n".to_vec(), + }), + ); + let out_delta = ep.collect_thread_events(&delta); + assert_eq!(out_delta, Vec::::new()); + + let end = event( + "d3", + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: Duration::from_millis(3), + formatted_output: String::new(), + }), + ); + let out_end = ep.collect_thread_events(&end); + assert_eq!( + out_end, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, + }), + }, + })] + ); +} + #[test] fn exec_command_end_failure_produces_failed_command_item() { let mut ep = EventProcessorWithJsonOutput::new(None); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index aa895d8dd3d..908cba1cc38 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -282,6 +282,7 @@ async fn run_codex_tool_session_inner( | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 89b5fd315a6..1e40618f01c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -518,6 +518,9 @@ pub enum EventMsg { /// Incremental chunk of output from a running command. ExecCommandOutputDelta(ExecCommandOutputDeltaEvent), + /// Terminal interaction for an in-progress command (stdin sent and stdout observed). + TerminalInteraction(TerminalInteractionEvent), + ExecCommandEnd(ExecCommandEndEvent), /// Notification that the agent attached a local image via the view_image tool. @@ -1455,6 +1458,17 @@ pub struct ExecCommandOutputDeltaEvent { pub chunk: Vec, } +#[serde_as] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct TerminalInteractionEvent { + /// Identifier for the ExecCommandBegin that produced this chunk. + pub call_id: String, + /// Process id associated with the running command. + pub process_id: String, + /// Stdin sent to the running session. + pub stdin: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct BackgroundEventEvent { pub message: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1d5ad24a033..dd623ed5837 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -46,6 +46,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_core::protocol::TurnAbortReason; @@ -825,6 +826,10 @@ impl ChatWidget { // TODO: Handle streaming exec output if/when implemented } + fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { + // TODO: Handle once design is ready + } + fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { self.add_to_history(history_cell::new_patch_event( event.changes, @@ -1022,11 +1027,7 @@ impl ChatWidget { } let (command, parsed, source) = match running { Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => ( - vec![ev.call_id.clone()], - Vec::new(), - ExecCommandSource::Agent, - ), + None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), }; let is_unified_exec_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); @@ -1043,7 +1044,7 @@ impl ChatWidget { command, parsed, source, - None, + ev.interaction_input.clone(), self.config.animations, ))); } @@ -1789,6 +1790,7 @@ impl ChatWidget { match msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) => {} _ => { tracing::trace!("handle_codex_event: {:?}", msg); @@ -1846,6 +1848,7 @@ impl ChatWidget { self.on_elicitation_request(ev); } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), + EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5cc5321f37f..fc1d25edfe7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1175,6 +1175,49 @@ fn exec_history_cell_shows_working_then_failed() { assert!(blob.to_lowercase().contains("bloop"), "expected error text"); } +#[test] +fn exec_end_without_begin_uses_event_command() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo orphaned".to_string(), + ]; + let parsed_cmd = codex_core::parse_command::parse_command(&command); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + chat.handle_codex_event(Event { + id: "call-orphan".to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-orphan".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done".to_string(), + stderr: String::new(), + aggregated_output: "done".to_string(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + formatted_output: "done".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("• Ran echo orphaned"), + "expected command text to come from event: {blob:?}" + ); + assert!( + !blob.contains("call-orphan"), + "call id should not be rendered when event has the command: {blob:?}" + ); +} + #[test] fn exec_history_shows_unified_exec_startup_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index dbaf4b81f76..cd98107ca05 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -94,10 +94,8 @@ impl ExecCommandSession { pub fn exit_code(&self) -> Option { self.exit_code.lock().ok().and_then(|guard| *guard) } -} -impl Drop for ExecCommandSession { - fn drop(&mut self) { + pub fn terminate(&self) { if let Ok(mut killer_opt) = self.killer.lock() { if let Some(mut killer) = killer_opt.take() { let _ = killer.kill(); @@ -122,6 +120,12 @@ impl Drop for ExecCommandSession { } } +impl Drop for ExecCommandSession { + fn drop(&mut self) { + self.terminate(); + } +} + #[derive(Debug)] pub struct SpawnedPty { pub session: ExecCommandSession, From 463249eff3ddab40ee17b1647dea3f39b23fa000 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 16:35:28 +0000 Subject: [PATCH 66/94] fix: flaky test 2 (#7818) --- codex-rs/core/src/shell_snapshot.rs | 30 ++++-- .../core/src/unified_exec/async_watcher.rs | 99 ++++++++++++++----- codex-rs/core/src/unified_exec/session.rs | 30 +++--- codex-rs/core/tests/suite/shell_snapshot.rs | 6 +- codex-rs/core/tests/suite/unified_exec.rs | 15 ++- 5 files changed, 126 insertions(+), 54 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 4df54997b72..2c4c423f5c8 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -367,6 +367,10 @@ mod tests { #[tokio::test] async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { use std::process::Stdio; + use tokio::time::Duration as TokioDuration; + use tokio::time::Instant; + use tokio::time::sleep; + let dir = tempdir()?; let shell_path = dir.path().join("hanging-shell.sh"); let pid_path = dir.path().join("pid"); @@ -402,16 +406,22 @@ mod tests { .trim() .parse::()?; - let kill_status = StdCommand::new("kill") - .arg("-0") - .arg(pid.to_string()) - .stderr(Stdio::null()) - .stdout(Stdio::null()) - .status()?; - assert!( - !kill_status.success(), - "timed out snapshot shell should be terminated" - ); + let deadline = Instant::now() + TokioDuration::from_secs(1); + loop { + let kill_status = StdCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .status()?; + if !kill_status.success() { + break; + } + if Instant::now() >= deadline { + panic!("timed out snapshot shell is still alive after grace period"); + } + sleep(TokioDuration::from_millis(50)).await; + } Ok(()) } diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 7412d29720f..19d91dbccf2 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -1,9 +1,11 @@ use std::path::PathBuf; +use std::pin::Pin; use std::sync::Arc; use tokio::sync::Mutex; use tokio::time::Duration; use tokio::time::Instant; +use tokio::time::Sleep; use crate::codex::Session; use crate::codex::TurnContext; @@ -21,6 +23,8 @@ use super::CommandTranscript; use super::UnifiedExecContext; use super::session::UnifiedExecSession; +pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); + /// Spawn a background task that continuously reads from the PTY, appends to the /// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8 /// boundaries. @@ -30,39 +34,58 @@ pub(crate) fn start_streaming_output( transcript: Arc>, ) { let mut receiver = session.output_receiver(); + let output_drained = session.output_drained_notify(); + let exit_token = session.cancellation_token(); + let session_ref = Arc::clone(&context.session); let turn_ref = Arc::clone(&context.turn); let call_id = context.call_id.clone(); - let cancellation_token = session.cancellation_token(); tokio::spawn(async move { - let mut pending: Vec = Vec::new(); + use tokio::sync::broadcast::error::RecvError; + + let mut pending = Vec::::new(); + + let mut grace_sleep: Option>> = None; + loop { tokio::select! { - _ = cancellation_token.cancelled() => break, - result = receiver.recv() => match result { - Ok(chunk) => { - pending.extend_from_slice(&chunk); - while let Some(prefix) = split_valid_utf8_prefix(&mut pending) { - { - let mut guard = transcript.lock().await; - guard.append(&prefix); - } - - let event = ExecCommandOutputDeltaEvent { - call_id: call_id.clone(), - stream: ExecOutputStream::Stdout, - chunk: prefix, - }; - session_ref - .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) - .await; - } + _ = exit_token.cancelled(), if grace_sleep.is_none() => { + let deadline = Instant::now() + TRAILING_OUTPUT_GRACE; + grace_sleep.replace(Box::pin(tokio::time::sleep_until(deadline))); + } + + _ = async { + if let Some(sleep) = grace_sleep.as_mut() { + sleep.as_mut().await; } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }, if grace_sleep.is_some() => { + output_drained.notify_one(); + break; } - }; + + received = receiver.recv() => { + let chunk = match received { + Ok(chunk) => chunk, + Err(RecvError::Lagged(_)) => { + continue; + }, + Err(RecvError::Closed) => { + output_drained.notify_one(); + break; + } + }; + + process_chunk( + &mut pending, + &transcript, + &call_id, + &session_ref, + &turn_ref, + chunk, + ).await; + } + } } }); } @@ -82,9 +105,11 @@ pub(crate) fn spawn_exit_watcher( started_at: Instant, ) { let exit_token = session.cancellation_token(); + let output_drained = session.output_drained_notify(); tokio::spawn(async move { exit_token.cancelled().await; + output_drained.notified().await; let exit_code = session.exit_code().unwrap_or(-1); let duration = Instant::now().saturating_duration_since(started_at); @@ -104,6 +129,32 @@ pub(crate) fn spawn_exit_watcher( }); } +async fn process_chunk( + pending: &mut Vec, + transcript: &Arc>, + call_id: &str, + session_ref: &Arc, + turn_ref: &Arc, + chunk: Vec, +) { + pending.extend_from_slice(&chunk); + while let Some(prefix) = split_valid_utf8_prefix(pending) { + { + let mut guard = transcript.lock().await; + guard.append(&prefix); + } + + let event = ExecCommandOutputDeltaEvent { + call_id: call_id.to_string(), + stream: ExecOutputStream::Stdout, + chunk: prefix, + }; + session_ref + .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) + .await; + } +} + /// Emit an ExecCommandEnd event for a unified exec session, using the transcript /// as the primary source of aggregated_output and falling back to the provided /// text when the transcript is empty. diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 51ebbd35696..4973a1a6417 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -79,6 +79,7 @@ pub(crate) struct UnifiedExecSession { output_buffer: OutputBuffer, output_notify: Arc, cancellation_token: CancellationToken, + output_drained: Arc, output_task: JoinHandle<()>, sandbox_type: SandboxType, } @@ -92,27 +93,21 @@ impl UnifiedExecSession { let output_buffer = Arc::new(Mutex::new(OutputBufferState::default())); let output_notify = Arc::new(Notify::new()); let cancellation_token = CancellationToken::new(); + let output_drained = Arc::new(Notify::new()); let mut receiver = initial_output_rx; let buffer_clone = Arc::clone(&output_buffer); let notify_clone = Arc::clone(&output_notify); - let cancellation_token_clone = cancellation_token.clone(); let output_task = tokio::spawn(async move { loop { - tokio::select! { - _ = cancellation_token_clone.cancelled() => break, - result = receiver.recv() => match result { - Ok(chunk) => { - let mut guard = buffer_clone.lock().await; - guard.push_chunk(chunk); - drop(guard); - notify_clone.notify_waiters(); - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - cancellation_token_clone.cancel(); - break; - } + match receiver.recv().await { + Ok(chunk) => { + let mut guard = buffer_clone.lock().await; + guard.push_chunk(chunk); + drop(guard); + notify_clone.notify_waiters(); } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, }; } }); @@ -122,6 +117,7 @@ impl UnifiedExecSession { output_buffer, output_notify, cancellation_token, + output_drained, output_task, sandbox_type, } @@ -147,6 +143,10 @@ impl UnifiedExecSession { self.cancellation_token.clone() } + pub(super) fn output_drained_notify(&self) -> Arc { + Arc::clone(&self.output_drained) + } + pub(super) fn has_exited(&self) -> bool { self.session.has_exited() } diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index f50e153ddc2..cc9d4ee77c8 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -132,6 +132,7 @@ fn assert_posix_snapshot_sections(snapshot: &str) { async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { let command = "echo snapshot-linux"; let run = run_snapshot_command(command).await?; + let stdout = normalize_newlines(&run.end.stdout); let shell_path = run .begin @@ -150,8 +151,11 @@ async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { assert!(run.snapshot_path.starts_with(&run.codex_home)); assert_posix_snapshot_sections(&run.snapshot_content); - assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-linux"); assert_eq!(run.end.exit_code, 0); + assert!( + stdout.contains("snapshot-linux"), + "stdout should contain snapshot marker; stdout={stdout:?}" + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 5def7aadb29..e2dcb0c5679 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -228,6 +228,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { false } EventMsg::ExecCommandBegin(event) if event.call_id == call_id => { + println!("Saw it"); saw_exec_begin = true; false } @@ -893,7 +894,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let open_call_id = "uexec-delayed-open"; let open_args = json!({ - "cmd": "sleep 5 && echo MARKER1 && sleep 5 && echo MARKER2", + "cmd": "sleep 3 && echo MARKER1 && sleep 3 && echo MARKER2", "yield_time_ms": 10, }); @@ -910,14 +911,14 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let second_poll_args = json!({ "chars": "", "session_id": 1000, - "yield_time_ms": 6000, + "yield_time_ms": 4000, }); let third_poll_call_id = "uexec-delayed-poll-3"; let third_poll_args = json!({ "chars": "", "session_id": 1000, - "yield_time_ms": 10000, + "yield_time_ms": 6000, }); let responses = vec![ @@ -984,6 +985,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let mut begin_event = None; let mut end_event = None; + let mut task_completed = false; let mut terminal_events = Vec::new(); let mut delta_text = String::new(); @@ -1003,8 +1005,13 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( EventMsg::ExecCommandEnd(ev) if ev.call_id == open_call_id => { end_event = Some(ev); } - EventMsg::TaskComplete(_) => break, + EventMsg::TaskComplete(_) => { + task_completed = true; + } _ => {} + }; + if task_completed && end_event.is_some() { + break; } } From 97b90094cdda249e99de33b7d44a50fe6c47ea4e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 17:04:52 +0000 Subject: [PATCH 67/94] feat: use remote branch for review is local trails (#7813) --- codex-rs/utils/git/src/branch.rs | 132 ++++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 10 deletions(-) diff --git a/codex-rs/utils/git/src/branch.rs b/codex-rs/utils/git/src/branch.rs index 543dffd9c95..f65de0c6bf0 100644 --- a/codex-rs/utils/git/src/branch.rs +++ b/codex-rs/utils/git/src/branch.rs @@ -7,7 +7,8 @@ use crate::operations::resolve_head; use crate::operations::resolve_repository_root; use crate::operations::run_git_for_stdout; -/// Returns the merge-base commit between `HEAD` and the provided branch, if both exist. +/// Returns the merge-base commit between `HEAD` and the latest version between local +/// and remote of the provided branch, if both exist. /// /// The function mirrors `git merge-base HEAD ` but returns `Ok(None)` when /// the repository has no `HEAD` yet or when the branch cannot be resolved. @@ -22,31 +23,97 @@ pub fn merge_base_with_head( None => return Ok(None), }; - let branch_ref = match run_git_for_stdout( + let Some(branch_ref) = resolve_branch_ref(repo_root.as_path(), branch)? else { + return Ok(None); + }; + + let preferred_ref = + if let Some(upstream) = resolve_upstream_if_remote_ahead(repo_root.as_path(), branch)? { + resolve_branch_ref(repo_root.as_path(), &upstream)?.unwrap_or(branch_ref) + } else { + branch_ref + }; + + let merge_base = run_git_for_stdout( repo_root.as_path(), + vec![ + OsString::from("merge-base"), + OsString::from(head), + OsString::from(preferred_ref), + ], + None, + )?; + + Ok(Some(merge_base)) +} + +fn resolve_branch_ref(repo_root: &Path, branch: &str) -> Result, GitToolingError> { + let rev = run_git_for_stdout( + repo_root, vec![ OsString::from("rev-parse"), OsString::from("--verify"), OsString::from(branch), ], None, + ); + + match rev { + Ok(rev) => Ok(Some(rev)), + Err(GitToolingError::GitCommand { .. }) => Ok(None), + Err(other) => Err(other), + } +} + +fn resolve_upstream_if_remote_ahead( + repo_root: &Path, + branch: &str, +) -> Result, GitToolingError> { + let upstream = match run_git_for_stdout( + repo_root, + vec![ + OsString::from("rev-parse"), + OsString::from("--abbrev-ref"), + OsString::from("--symbolic-full-name"), + OsString::from(format!("{branch}@{{upstream}}")), + ], + None, ) { - Ok(rev) => rev, + Ok(name) => { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Ok(None); + } + trimmed.to_string() + } Err(GitToolingError::GitCommand { .. }) => return Ok(None), Err(other) => return Err(other), }; - let merge_base = run_git_for_stdout( - repo_root.as_path(), + let counts = match run_git_for_stdout( + repo_root, vec![ - OsString::from("merge-base"), - OsString::from(head), - OsString::from(branch_ref), + OsString::from("rev-list"), + OsString::from("--left-right"), + OsString::from("--count"), + OsString::from(format!("{branch}...{upstream}")), ], None, - )?; + ) { + Ok(counts) => counts, + Err(GitToolingError::GitCommand { .. }) => return Ok(None), + Err(other) => return Err(other), + }; - Ok(Some(merge_base)) + let mut parts = counts.split_whitespace(); + let _left: i64 = parts.next().unwrap_or("0").parse().unwrap_or(0); + let right: i64 = parts.next().unwrap_or("0").parse().unwrap_or(0); + + if right > 0 { + Ok(Some(upstream)) + } else { + Ok(None) + } } #[cfg(test)] @@ -126,6 +193,51 @@ mod tests { Ok(()) } + #[test] + fn merge_base_prefers_upstream_when_remote_ahead() -> Result<(), GitToolingError> { + let temp = tempdir()?; + let repo = temp.path().join("repo"); + let remote = temp.path().join("remote.git"); + std::fs::create_dir_all(&repo)?; + std::fs::create_dir_all(&remote)?; + + run_git_in(&remote, &["init", "--bare"]); + run_git_in(&repo, &["init", "--initial-branch=main"]); + run_git_in(&repo, &["config", "core.autocrlf", "false"]); + + std::fs::write(repo.join("base.txt"), "base\n")?; + run_git_in(&repo, &["add", "base.txt"]); + commit(&repo, "base commit"); + + run_git_in( + &repo, + &["remote", "add", "origin", remote.to_str().unwrap()], + ); + run_git_in(&repo, &["push", "-u", "origin", "main"]); + + run_git_in(&repo, &["checkout", "-b", "feature"]); + std::fs::write(repo.join("feature.txt"), "feature change\n")?; + run_git_in(&repo, &["add", "feature.txt"]); + commit(&repo, "feature commit"); + + run_git_in(&repo, &["checkout", "--orphan", "rewrite"]); + run_git_in(&repo, &["rm", "-rf", "."]); + std::fs::write(repo.join("new-main.txt"), "rewritten main\n")?; + run_git_in(&repo, &["add", "new-main.txt"]); + commit(&repo, "rewrite main"); + run_git_in(&repo, &["branch", "-M", "rewrite", "main"]); + run_git_in(&repo, &["branch", "--set-upstream-to=origin/main", "main"]); + + run_git_in(&repo, &["checkout", "feature"]); + run_git_in(&repo, &["fetch", "origin"]); + + let expected = run_git_stdout(&repo, &["merge-base", "HEAD", "origin/main"]); + let merge_base = merge_base_with_head(&repo, "main")?; + assert_eq!(merge_base, Some(expected)); + + Ok(()) + } + #[test] fn merge_base_returns_none_when_branch_missing() -> Result<(), GitToolingError> { let temp = tempdir()?; From e0fb3ca1dbea0c418cf8b3c7b76ed671d62147e3 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Wed, 10 Dec 2025 09:18:48 -0800 Subject: [PATCH 68/94] refactoring with_escalated_permissions to use SandboxPermissions instead (#7750) helpful in the future if we want more granularity for requesting escalated permissions: e.g when running in readonly sandbox, model can request to escalate to a sandbox that allows writes --- .../app-server/src/codex_message_processor.rs | 3 +- codex-rs/core/gpt-5.1-codex-max_prompt.md | 6 +- codex-rs/core/gpt_5_1_prompt.md | 6 +- codex-rs/core/gpt_5_codex_prompt.md | 6 +- codex-rs/core/src/codex.rs | 13 +- codex-rs/core/src/exec.rs | 15 +- codex-rs/core/src/sandboxing/mod.rs | 29 +--- codex-rs/core/src/tasks/user_shell.rs | 3 +- codex-rs/core/src/tools/handlers/shell.rs | 23 ++- .../core/src/tools/handlers/unified_exec.rs | 9 +- codex-rs/core/src/tools/router.rs | 3 +- .../core/src/tools/runtimes/apply_patch.rs | 3 +- codex-rs/core/src/tools/runtimes/mod.rs | 5 +- codex-rs/core/src/tools/runtimes/shell.rs | 11 +- .../core/src/tools/runtimes/unified_exec.rs | 15 +- codex-rs/core/src/tools/spec.rs | 24 ++-- codex-rs/core/src/unified_exec/mod.rs | 5 +- .../core/src/unified_exec/session_manager.rs | 8 +- codex-rs/core/tests/suite/approvals.rs | 131 ++++++++++-------- codex-rs/core/tests/suite/codex_delegate.rs | 3 +- codex-rs/core/tests/suite/exec.rs | 3 +- codex-rs/core/tests/suite/tools.rs | 3 +- codex-rs/exec-server/src/posix.rs | 16 ++- .../exec-server/src/posix/escalate_server.rs | 3 +- .../src/posix/mcp_escalation_policy.rs | 13 +- .../linux-sandbox/tests/suite/landlock.rs | 5 +- codex-rs/protocol/src/models.rs | 31 ++++- 27 files changed, 216 insertions(+), 179 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0a8445055dc..8576c5c381c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -136,6 +136,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget as CoreReviewTarget; use codex_core::protocol::SessionConfiguredEvent; use codex_core::read_head_for_summary; +use codex_core::sandboxing::SandboxPermissions; use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; @@ -1191,7 +1192,7 @@ impl CodexMessageProcessor { cwd, expiration: timeout_ms.into(), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/gpt-5.1-codex-max_prompt.md b/codex-rs/core/gpt-5.1-codex-max_prompt.md index 292e5d7d0f1..a8227c893f0 100644 --- a/codex-rs/core/gpt-5.1-codex-max_prompt.md +++ b/codex-rs/core/gpt-5.1-codex-max_prompt.md @@ -48,7 +48,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - 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. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- 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 `sandbox_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) @@ -59,8 +59,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo 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 + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Special user requests diff --git a/codex-rs/core/gpt_5_1_prompt.md b/codex-rs/core/gpt_5_1_prompt.md index 97a3875fe57..3201ffeb684 100644 --- a/codex-rs/core/gpt_5_1_prompt.md +++ b/codex-rs/core/gpt_5_1_prompt.md @@ -182,7 +182,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - 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. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language. +- 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 `sandbox_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language. - 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) @@ -193,8 +193,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo 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 + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Validating your work diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index 57d06761ba2..e2f9017874a 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -48,7 +48,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - 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. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- 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 `sandbox_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) @@ -59,8 +59,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo 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 + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Special user requests diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 042ae1a37a5..4129bb6a1f0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3325,6 +3325,7 @@ mod tests { use crate::exec::ExecParams; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; + use crate::sandboxing::SandboxPermissions; use crate::turn_diff_tracker::TurnDiffTracker; use std::collections::HashMap; @@ -3335,6 +3336,7 @@ mod tests { let mut turn_context = Arc::new(turn_context_raw); let timeout_ms = 1000; + let sandbox_permissions = SandboxPermissions::RequireEscalated; let params = ExecParams { command: if cfg!(windows) { vec![ @@ -3352,13 +3354,13 @@ mod tests { cwd: turn_context.cwd.clone(), expiration: timeout_ms.into(), env: HashMap::new(), - with_escalated_permissions: Some(true), + sandbox_permissions, justification: Some("test".to_string()), arg0: None, }; let params2 = ExecParams { - with_escalated_permissions: Some(false), + sandbox_permissions: SandboxPermissions::UseDefault, command: params.command.clone(), cwd: params.cwd.clone(), expiration: timeout_ms.into(), @@ -3385,7 +3387,7 @@ mod tests { "command": params.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": params.expiration.timeout_ms(), - "with_escalated_permissions": params.with_escalated_permissions, + "sandbox_permissions": params.sandbox_permissions, "justification": params.justification.clone(), }) .to_string(), @@ -3422,7 +3424,7 @@ mod tests { "command": params2.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": params2.expiration.timeout_ms(), - "with_escalated_permissions": params2.with_escalated_permissions, + "sandbox_permissions": params2.sandbox_permissions, "justification": params2.justification.clone(), }) .to_string(), @@ -3455,6 +3457,7 @@ mod tests { #[tokio::test] async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { use crate::protocol::AskForApproval; + use crate::sandboxing::SandboxPermissions; use crate::turn_diff_tracker::TurnDiffTracker; let (session, mut turn_context_raw) = make_session_and_context(); @@ -3474,7 +3477,7 @@ mod tests { payload: ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo hi", - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, "justification": "need unsandboxed execution", }) .to_string(), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index ba1ac430040..596f325059d 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -28,6 +28,7 @@ use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxManager; +use crate::sandboxing::SandboxPermissions; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; @@ -55,7 +56,7 @@ pub struct ExecParams { pub cwd: PathBuf, pub expiration: ExecExpiration, pub env: HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub arg0: Option, } @@ -144,7 +145,7 @@ pub async fn process_exec_tool_call( cwd, expiration, env, - with_escalated_permissions, + sandbox_permissions, justification, arg0: _, } = params; @@ -162,7 +163,7 @@ pub async fn process_exec_tool_call( cwd, env, expiration, - with_escalated_permissions, + sandbox_permissions, justification, }; @@ -192,7 +193,7 @@ pub(crate) async fn execute_exec_env( env, expiration, sandbox, - with_escalated_permissions, + sandbox_permissions, justification, arg0, } = env; @@ -202,7 +203,7 @@ pub(crate) async fn execute_exec_env( cwd, expiration, env, - with_escalated_permissions, + sandbox_permissions, justification, arg0, }; @@ -857,7 +858,7 @@ mod tests { cwd: std::env::current_dir()?, expiration: 500.into(), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; @@ -902,7 +903,7 @@ mod tests { cwd: cwd.clone(), expiration: ExecExpiration::Cancellation(cancel_token), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index d43646021ee..3f56ce3ae9f 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -23,32 +23,11 @@ use crate::seatbelt::create_seatbelt_command_args; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use crate::tools::sandboxing::SandboxablePreference; +pub use codex_protocol::models::SandboxPermissions; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SandboxPermissions { - UseDefault, - RequireEscalated, -} - -impl SandboxPermissions { - pub fn requires_escalated_permissions(self) -> bool { - matches!(self, SandboxPermissions::RequireEscalated) - } -} - -impl From for SandboxPermissions { - fn from(with_escalated_permissions: bool) -> Self { - if with_escalated_permissions { - SandboxPermissions::RequireEscalated - } else { - SandboxPermissions::UseDefault - } - } -} - #[derive(Debug)] pub struct CommandSpec { pub program: String, @@ -56,7 +35,7 @@ pub struct CommandSpec { pub cwd: PathBuf, pub env: HashMap, pub expiration: ExecExpiration, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, } @@ -67,7 +46,7 @@ pub struct ExecEnv { pub env: HashMap, pub expiration: ExecExpiration, pub sandbox: SandboxType, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub arg0: Option, } @@ -181,7 +160,7 @@ impl SandboxManager { env, expiration: spec.expiration, sandbox, - with_escalated_permissions: spec.with_escalated_permissions, + sandbox_permissions: spec.sandbox_permissions, justification: spec.justification, arg0: arg0_override, }) diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index ca5243241a2..aec09514ca3 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -24,6 +24,7 @@ use crate::protocol::ExecCommandSource; use crate::protocol::SandboxPolicy; use crate::protocol::TaskStartedEvent; use crate::sandboxing::ExecEnv; +use crate::sandboxing::SandboxPermissions; use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::user_shell_command::user_shell_command_record_item; @@ -100,7 +101,7 @@ impl SessionTask for UserShellCommandTask { // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), sandbox: SandboxType::None, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 98bd883d134..9c306a186ee 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -10,7 +10,6 @@ use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::ExecCommandSource; -use crate::sandboxing::SandboxPermissions; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -35,7 +34,7 @@ impl ShellHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), - with_escalated_permissions: params.with_escalated_permissions, + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, } @@ -56,7 +55,7 @@ impl ShellCommandHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), - with_escalated_permissions: params.with_escalated_permissions, + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, } @@ -206,7 +205,9 @@ impl ShellHandler { freeform: bool, ) -> Result { // Approval policy guard for explicit escalation in non-OnRequest modes. - if exec_params.with_escalated_permissions.unwrap_or(false) + if exec_params + .sandbox_permissions + .requires_escalated_permissions() && !matches!( turn.approval_policy, codex_protocol::protocol::AskForApproval::OnRequest @@ -251,7 +252,7 @@ impl ShellHandler { &exec_params.command, turn.approval_policy, &turn.sandbox_policy, - SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)), + exec_params.sandbox_permissions, ) .await; @@ -260,7 +261,7 @@ impl ShellHandler { cwd: exec_params.cwd.clone(), timeout_ms: exec_params.expiration.timeout_ms(), env: exec_params.env.clone(), - with_escalated_permissions: exec_params.with_escalated_permissions, + sandbox_permissions: exec_params.sandbox_permissions, justification: exec_params.justification.clone(), exec_approval_requirement, }; @@ -295,6 +296,7 @@ mod tests { use crate::codex::make_session_and_context; use crate::exec_env::create_env; use crate::is_safe_command::is_known_safe_command; + use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::handlers::ShellCommandHandler; @@ -343,7 +345,7 @@ mod tests { let workdir = Some("subdir".to_string()); let login = None; let timeout_ms = Some(1234); - let with_escalated_permissions = Some(true); + let sandbox_permissions = SandboxPermissions::RequireEscalated; let justification = Some("because tests".to_string()); let expected_command = session.user_shell().derive_exec_args(&command, true); @@ -355,7 +357,7 @@ mod tests { workdir, login, timeout_ms, - with_escalated_permissions, + sandbox_permissions: Some(sandbox_permissions), justification: justification.clone(), }; @@ -366,10 +368,7 @@ mod tests { assert_eq!(exec_params.cwd, expected_cwd); assert_eq!(exec_params.env, expected_env); assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); - assert_eq!( - exec_params.with_escalated_permissions, - with_escalated_permissions - ); + assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); assert_eq!(exec_params.justification, justification); assert_eq!(exec_params.arg0, None); } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index abaaf4a7abe..0d3a11da106 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -3,6 +3,7 @@ use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; use crate::protocol::ExecCommandSource; use crate::protocol::TerminalInteractionEvent; +use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; @@ -40,7 +41,7 @@ struct ExecCommandArgs { #[serde(default)] max_output_tokens: Option, #[serde(default)] - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, #[serde(default)] justification: Option, } @@ -131,12 +132,12 @@ impl ToolHandler for UnifiedExecHandler { login, yield_time_ms, max_output_tokens, - with_escalated_permissions, + sandbox_permissions, justification, .. } = args; - if with_escalated_permissions.unwrap_or(false) + if sandbox_permissions.requires_escalated_permissions() && !matches!( context.turn.approval_policy, codex_protocol::protocol::AskForApproval::OnRequest @@ -200,7 +201,7 @@ impl ToolHandler for UnifiedExecHandler { yield_time_ms, max_output_tokens, workdir, - with_escalated_permissions, + sandbox_permissions, justification, }, &context, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 7152d3c1ec9..b6675bcd5d1 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -5,6 +5,7 @@ use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; +use crate::sandboxing::SandboxPermissions; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -114,7 +115,7 @@ impl ToolRouter { command: exec.command, workdir: exec.working_directory, timeout_ms: exec.timeout_ms, - with_escalated_permissions: None, + sandbox_permissions: Some(SandboxPermissions::UseDefault), justification: None, }; Ok(Some(ToolCall { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 7ef8d33767a..bf4b66ce9d3 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -7,6 +7,7 @@ use crate::CODEX_APPLY_PATCH_ARG1; use crate::exec::ExecToolCallOutput; use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -70,7 +71,7 @@ impl ApplyPatchRuntime { expiration: req.timeout_ms.into(), // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, }) } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 437f4af428b..2431b3c97d3 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -6,6 +6,7 @@ small and focused and reuses the orchestrator for approvals + sandbox + retry. */ use crate::exec::ExecExpiration; use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ToolError; use std::collections::HashMap; use std::path::Path; @@ -21,7 +22,7 @@ pub(crate) fn build_command_spec( cwd: &Path, env: &HashMap, expiration: ExecExpiration, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, ) -> Result { let (program, args) = command @@ -33,7 +34,7 @@ pub(crate) fn build_command_spec( cwd: cwd.to_path_buf(), env: env.clone(), expiration, - with_escalated_permissions, + sandbox_permissions, justification, }) } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 50b6a6785ad..595bda0e967 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -5,6 +5,7 @@ Executes shell requests under the orchestrator: asks for approval when needed, builds a CommandSpec, and runs it under the current SandboxAttempt. */ use crate::exec::ExecToolCallOutput; +use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; @@ -30,7 +31,7 @@ pub struct ShellRequest { pub cwd: PathBuf, pub timeout_ms: Option, pub env: std::collections::HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -51,7 +52,7 @@ pub struct ShellRuntime; pub(crate) struct ApprovalKey { command: Vec, cwd: PathBuf, - escalated: bool, + sandbox_permissions: SandboxPermissions, } impl ShellRuntime { @@ -84,7 +85,7 @@ impl Approvable for ShellRuntime { ApprovalKey { command: req.command.clone(), cwd: req.cwd.clone(), - escalated: req.with_escalated_permissions.unwrap_or(false), + sandbox_permissions: req.sandbox_permissions, } } @@ -129,7 +130,7 @@ impl Approvable for ShellRuntime { } fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride { - if req.with_escalated_permissions.unwrap_or(false) + if req.sandbox_permissions.requires_escalated_permissions() || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { @@ -157,7 +158,7 @@ impl ToolRuntime for ShellRuntime { &req.cwd, &req.env, req.timeout_ms.into(), - req.with_escalated_permissions, + req.sandbox_permissions, req.justification.clone(), )?; let env = attempt diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index d21e6de1e24..b6a8047080f 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -7,6 +7,7 @@ the session manager to spawn PTYs once an ExecEnv is prepared. use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecExpiration; +use crate::sandboxing::SandboxPermissions; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -34,7 +35,7 @@ pub struct UnifiedExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -52,7 +53,7 @@ impl ProvidesSandboxRetryData for UnifiedExecRequest { pub struct UnifiedExecApprovalKey { pub command: Vec, pub cwd: PathBuf, - pub escalated: bool, + pub sandbox_permissions: SandboxPermissions, } pub struct UnifiedExecRuntime<'a> { @@ -64,7 +65,7 @@ impl UnifiedExecRequest { command: Vec, cwd: PathBuf, env: HashMap, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, exec_approval_requirement: ExecApprovalRequirement, ) -> Self { @@ -72,7 +73,7 @@ impl UnifiedExecRequest { command, cwd, env, - with_escalated_permissions, + sandbox_permissions, justification, exec_approval_requirement, } @@ -102,7 +103,7 @@ impl Approvable for UnifiedExecRuntime<'_> { UnifiedExecApprovalKey { command: req.command.clone(), cwd: req.cwd.clone(), - escalated: req.with_escalated_permissions.unwrap_or(false), + sandbox_permissions: req.sandbox_permissions, } } @@ -150,7 +151,7 @@ impl Approvable for UnifiedExecRuntime<'_> { } fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride { - if req.with_escalated_permissions.unwrap_or(false) + if req.sandbox_permissions.requires_escalated_permissions() || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { @@ -178,7 +179,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &req.cwd, &req.env, ExecExpiration::DefaultTimeout, - req.with_escalated_permissions, + req.sandbox_permissions, req.justification.clone(), ) .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 89a71b0edc4..0b74b9e10a8 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -174,10 +174,10 @@ fn create_exec_command_tool() -> ToolSpec { }, ); properties.insert( - "with_escalated_permissions".to_string(), - JsonSchema::Boolean { + "sandbox_permissions".to_string(), + JsonSchema::String { description: Some( - "Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions" + "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." .to_string(), ), }, @@ -186,7 +186,7 @@ fn create_exec_command_tool() -> ToolSpec { "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." + "Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command." .to_string(), ), }, @@ -274,15 +274,15 @@ fn create_shell_tool() -> ToolSpec { ); 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()), + "sandbox_permissions".to_string(), + JsonSchema::String { + description: Some("Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\".".to_string()), }, ); 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()), + description: Some("Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command.".to_string()), }, ); @@ -347,15 +347,15 @@ fn create_shell_command_tool() -> ToolSpec { }, ); 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()), + "sandbox_permissions".to_string(), + JsonSchema::String { + description: Some("Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\".".to_string()), }, ); 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()), + description: Some("Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command.".to_string()), }, ); diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 0d86b69fda8..814001f41fe 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -33,6 +33,7 @@ use tokio::sync::Mutex; use crate::codex::Session; use crate::codex::TurnContext; +use crate::sandboxing::SandboxPermissions; mod async_watcher; mod errors; @@ -93,7 +94,7 @@ pub(crate) struct ExecCommandRequest { pub yield_time_ms: u64, pub max_output_tokens: Option, pub workdir: Option, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, } @@ -217,7 +218,7 @@ mod tests { yield_time_ms, max_output_tokens: None, workdir: None, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, }, &context, diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index fa64eb4bb2c..4b24c574ac2 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -126,7 +126,7 @@ impl UnifiedExecSessionManager { .open_session_with_sandbox( &request.command, cwd.clone(), - request.with_escalated_permissions, + request.sandbox_permissions, request.justification, context, ) @@ -476,7 +476,7 @@ impl UnifiedExecSessionManager { &self, command: &[String], cwd: PathBuf, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, context: &UnifiedExecContext, ) -> Result { @@ -490,14 +490,14 @@ impl UnifiedExecSessionManager { command, context.turn.approval_policy, &context.turn.sandbox_policy, - SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)), + sandbox_permissions, ) .await; let req = UnifiedExecToolRequest::new( command.to_vec(), cwd, env, - with_escalated_permissions, + sandbox_permissions, justification, exec_approval_requirement, ); diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 4570e6a5b94..879ad56d479 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -9,6 +9,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::ReviewDecision; use codex_protocol::user_input::UserInput; @@ -96,14 +97,14 @@ impl ActionKind { test: &TestCodex, server: &MockServer, call_id: &str, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, ) -> Result<(Value, Option)> { match self { ActionKind::WriteFile { target, content } => { let (path, _) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); let command = format!("printf {content:?} > {path:?} && cat {path:?}"); - let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::FetchUrl { @@ -125,11 +126,11 @@ impl ActionKind { ); let command = format!("python3 -c \"{script}\""); - let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::RunCommand { command } => { - let event = shell_event(call_id, command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, command, 1_000, sandbox_permissions)?; Ok((event, Some(command.to_string()))) } ActionKind::RunUnifiedExecCommand { @@ -140,7 +141,7 @@ impl ActionKind { call_id, command, Some(1000), - with_escalated_permissions, + sandbox_permissions, *justification, )?; Ok((event, Some(command.to_string()))) @@ -156,7 +157,7 @@ impl ActionKind { let _ = fs::remove_file(&path); let patch = build_add_file_patch(&patch_path, content); let command = shell_apply_patch_command(&patch); - let event = shell_event(call_id, &command, 5_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?; Ok((event, Some(command))) } } @@ -181,14 +182,14 @@ fn shell_event( call_id: &str, command: &str, timeout_ms: u64, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, ) -> Result { let mut args = json!({ "command": command, "timeout_ms": timeout_ms, }); - if with_escalated_permissions { - args["with_escalated_permissions"] = json!(true); + if sandbox_permissions.requires_escalated_permissions() { + args["sandbox_permissions"] = json!(sandbox_permissions); } let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "shell_command", &args_str)) @@ -198,7 +199,7 @@ fn exec_command_event( call_id: &str, cmd: &str, yield_time_ms: Option, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, justification: Option<&str>, ) -> Result { let mut args = json!({ @@ -207,8 +208,8 @@ fn exec_command_event( if let Some(yield_time_ms) = yield_time_ms { args["yield_time_ms"] = json!(yield_time_ms); } - if with_escalated_permissions { - args["with_escalated_permissions"] = json!(true); + if sandbox_permissions.requires_escalated_permissions() { + args["sandbox_permissions"] = json!(sandbox_permissions); let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); args["justification"] = json!(reason); } @@ -466,7 +467,7 @@ struct ScenarioSpec { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, action: ActionKind, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, features: Vec, model_override: Option<&'static str>, outcome: Outcome, @@ -637,7 +638,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), content: "danger-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -654,7 +655,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"), content: "danger-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -671,7 +672,7 @@ fn scenarios() -> Vec { endpoint: "/dfa/network", response_body: "danger-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -687,7 +688,7 @@ fn scenarios() -> Vec { endpoint: "/dfa/network", response_body: "danger-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -702,7 +703,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-unless", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -717,7 +718,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-unless", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -733,7 +734,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), content: "danger-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -750,7 +751,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_failure_5_1.txt"), content: "danger-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -767,7 +768,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), content: "danger-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -787,7 +788,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.txt"), content: "danger-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -807,7 +808,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_never.txt"), content: "danger-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -824,7 +825,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"), content: "danger-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -841,7 +842,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request.txt"), content: "read-only-approval", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -861,7 +862,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request_5_1.txt"), content: "read-only-approval", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -880,7 +881,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-read-only", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -895,7 +896,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-read-only", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -911,7 +912,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-blocked", response_body: "should-not-see", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -925,7 +926,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request_denied.txt"), content: "should-not-write", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: None, outcome: Outcome::ExecApproval { @@ -946,7 +947,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_failure.txt"), content: "read-only-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -967,7 +968,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_failure_5_1.txt"), content: "read-only-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -987,7 +988,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1006,7 +1007,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -1025,7 +1026,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_shell.txt"), content: "shell-apply-patch", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::PatchApproval { @@ -1045,7 +1046,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_function.txt"), content: "function-apply-patch", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1062,7 +1063,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), content: "function-patch-danger", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::ApplyPatchFreeform], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1079,7 +1080,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), content: "function-patch-outside", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1099,7 +1100,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), content: "function-patch-outside-denied", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1119,7 +1120,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), content: "shell-patch-outside", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::PatchApproval { @@ -1139,7 +1140,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), content: "function-patch-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1159,7 +1160,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), content: "function-patch-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1178,7 +1179,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_unless_trusted.txt"), content: "read-only-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1198,7 +1199,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"), content: "read-only-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -1218,7 +1219,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_never.txt"), content: "read-only-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1241,7 +1242,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1257,7 +1258,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ww_on_request.txt"), content: "workspace-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1274,7 +1275,7 @@ fn scenarios() -> Vec { endpoint: "/ww/network-blocked", response_body: "workspace-network-blocked", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1288,7 +1289,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), content: "workspace-on-request-outside", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1308,7 +1309,7 @@ fn scenarios() -> Vec { endpoint: "/ww/network-ok", response_body: "workspace-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1325,7 +1326,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), content: "workspace-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1345,7 +1346,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), content: "workspace-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1365,7 +1366,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_never.txt"), content: "workspace-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1389,7 +1390,7 @@ fn scenarios() -> Vec { command: "echo \"hello unified exec\"", justification: None, }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1407,7 +1408,7 @@ fn scenarios() -> Vec { command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'", justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![Feature::UnifiedExec], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1426,7 +1427,7 @@ fn scenarios() -> Vec { command: "git reset --hard", justification: None, }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], model_override: None, outcome: Outcome::ExecApproval { @@ -1472,7 +1473,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { let call_id = scenario.name; let (event, expected_command) = scenario .action - .prepare(&test, &server, call_id, scenario.with_escalated_permissions) + .prepare(&test, &server, call_id, scenario.sandbox_permissions) .await?; let _ = mount_sse_once( @@ -1578,7 +1579,12 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts let (first_event, expected_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } - .prepare(&test, &server, call_id_first, false) + .prepare( + &test, + &server, + call_id_first, + SandboxPermissions::UseDefault, + ) .await?; let expected_command = expected_command.expect("execpolicy amendment scenario should produce a shell command"); @@ -1656,7 +1662,12 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts let (second_event, second_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } - .prepare(&test, &server, call_id_second, false) + .prepare( + &test, + &server, + call_id_second, + SandboxPermissions::UseDefault, + ) .await?; assert_eq!(second_command.as_deref(), Some(expected_command.as_str())); diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index f5fe1a7df92..2bd156d6a86 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -5,6 +5,7 @@ use codex_core::protocol::ReviewDecision; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -31,7 +32,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { let args = serde_json::json!({ "command": "rm -rf delegated", "timeout_ms": 1000, - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, }) .to_string(); let sse1 = sse(vec![ diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 6c2283107bd..c0934821570 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -8,6 +8,7 @@ use codex_core::exec::ExecToolCallOutput; use codex_core::exec::SandboxType; use codex_core::exec::process_exec_tool_call; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use tempfile::TempDir; @@ -34,7 +35,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result Result<()> { let first_args = json!({ "command": command, "timeout_ms": 1_000, - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, }); let second_args = json!({ "command": command, diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 1a4b0a0e1fc..ba481264e2f 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -63,6 +63,7 @@ use anyhow::Context as _; use clap::Parser; use codex_core::config::find_codex_home; use codex_core::is_dangerous_command::command_might_be_dangerous; +use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; @@ -202,13 +203,19 @@ pub(crate) fn evaluate_exec_policy( && rule_match.decision() == evaluation.decision }); + let sandbox_permissions = if decision_driven_by_policy { + SandboxPermissions::RequireEscalated + } else { + SandboxPermissions::UseDefault + }; + Ok(match evaluation.decision { Decision::Forbidden => ExecPolicyOutcome::Forbidden, Decision::Prompt => ExecPolicyOutcome::Prompt { - run_with_escalated_permissions: decision_driven_by_policy, + sandbox_permissions, }, Decision::Allow => ExecPolicyOutcome::Allow { - run_with_escalated_permissions: decision_driven_by_policy, + sandbox_permissions, }, }) } @@ -231,6 +238,7 @@ async fn load_exec_policy() -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use pretty_assertions::assert_eq; @@ -247,7 +255,7 @@ mod tests { assert_eq!( outcome, ExecPolicyOutcome::Prompt { - run_with_escalated_permissions: false + sandbox_permissions: SandboxPermissions::UseDefault } ); } @@ -276,7 +284,7 @@ mod tests { assert_eq!( outcome, ExecPolicyOutcome::Allow { - run_with_escalated_permissions: true + sandbox_permissions: SandboxPermissions::RequireEscalated } ); } diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index 72934607a36..d99f3007040 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -10,6 +10,7 @@ use path_absolutize::Absolutize as _; use codex_core::SandboxState; use codex_core::exec::process_exec_tool_call; +use codex_core::sandboxing::SandboxPermissions; use tokio::process::Command; use tokio_util::sync::CancellationToken; @@ -85,7 +86,7 @@ impl EscalateServer { cwd: PathBuf::from(&workdir), expiration: ExecExpiration::Cancellation(cancel_rx), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }, diff --git a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs index 97e76a68447..6d0c1bb3380 100644 --- a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs +++ b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs @@ -1,5 +1,6 @@ use std::path::Path; +use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Policy; use rmcp::ErrorData as McpError; use rmcp::RoleServer; @@ -18,10 +19,10 @@ use tokio::sync::RwLock; #[derive(Debug, PartialEq, Eq)] pub(crate) enum ExecPolicyOutcome { Allow { - run_with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, }, Prompt { - run_with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, }, Forbidden, } @@ -108,16 +109,16 @@ impl EscalationPolicy for McpEscalationPolicy { crate::posix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?; let action = match outcome { ExecPolicyOutcome::Allow { - run_with_escalated_permissions, + sandbox_permissions, } => { - if run_with_escalated_permissions { + if sandbox_permissions.requires_escalated_permissions() { EscalateAction::Escalate } else { EscalateAction::Run } } ExecPolicyOutcome::Prompt { - run_with_escalated_permissions, + sandbox_permissions, } => { let result = self .prompt(file, argv, workdir, self.context.clone()) @@ -125,7 +126,7 @@ impl EscalationPolicy for McpEscalationPolicy { // TODO: Extract reason from `result.content`. match result.action { ElicitationAction::Accept => { - if run_with_escalated_permissions { + if sandbox_permissions.requires_escalated_permissions() { EscalateAction::Escalate } else { EscalateAction::Run diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index e145aa2f739..791f9b1ea7e 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -6,6 +6,7 @@ use codex_core::exec::ExecParams; use codex_core::exec::process_exec_tool_call; use codex_core::exec_env::create_env; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use std::collections::HashMap; use std::path::PathBuf; use tempfile::NamedTempFile; @@ -41,7 +42,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { cwd, expiration: timeout_ms.into(), env: create_env_from_core_vars(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; @@ -143,7 +144,7 @@ async fn assert_network_blocked(cmd: &[&str]) { // do not stall the suite. expiration: NETWORK_TIMEOUT_MS.into(), env: create_env_from_core_vars(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 9f66d08dca5..51e977cb958 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -14,6 +14,25 @@ use codex_git::GhostCommit; use codex_utils_image::error::ImageProcessingError; use schemars::JsonSchema; +/// Controls whether a command should use the session sandbox or bypass it. +#[derive( + Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS, +)] +#[serde(rename_all = "snake_case")] +pub enum SandboxPermissions { + /// Run with the configured sandbox + #[default] + UseDefault, + /// Request to run outside the sandbox + RequireEscalated, +} + +impl SandboxPermissions { + pub fn requires_escalated_permissions(self) -> bool { + matches!(self, SandboxPermissions::RequireEscalated) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputItem { @@ -327,8 +346,9 @@ pub struct ShellToolCallParams { /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub with_escalated_permissions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub sandbox_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -346,8 +366,9 @@ pub struct ShellCommandToolCallParams { /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub with_escalated_permissions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub sandbox_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -742,7 +763,7 @@ mod tests { command: vec!["ls".to_string(), "-l".to_string()], workdir: Some("/tmp".to_string()), timeout_ms: Some(1000), - with_escalated_permissions: None, + sandbox_permissions: None, justification: None, }, params From c4af707e09b91cae7ccb83569596d1725eeefebe Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 10 Dec 2025 11:48:11 -0600 Subject: [PATCH 69/94] Removed experimental "command risk assessment" feature (#7799) This experimental feature received lukewarm reception during internal testing. Removing from the code base. --- codex-rs/Cargo.lock | 52 ---- codex-rs/Cargo.toml | 1 - .../src/protocol/common.rs | 2 - .../app-server-protocol/src/protocol/v1.rs | 2 - .../app-server-protocol/src/protocol/v2.rs | 37 --- codex-rs/app-server-test-client/src/main.rs | 4 - .../app-server/src/bespoke_event_handling.rs | 6 +- .../suite/codex_message_processor_flow.rs | 1 - codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/codex.rs | 31 -- codex-rs/core/src/codex_delegate.rs | 1 - codex-rs/core/src/config/mod.rs | 14 - codex-rs/core/src/config/profile.rs | 1 - codex-rs/core/src/features.rs | 12 - codex-rs/core/src/features/legacy.rs | 11 - codex-rs/core/src/sandboxing/assessment.rs | 268 ------------------ codex-rs/core/src/sandboxing/mod.rs | 2 - codex-rs/core/src/tools/orchestrator.rs | 39 --- .../core/src/tools/runtimes/apply_patch.rs | 10 - codex-rs/core/src/tools/runtimes/shell.rs | 13 - .../core/src/tools/runtimes/unified_exec.rs | 13 - codex-rs/core/src/tools/sandboxing.rs | 14 - .../templates/sandboxing/assessment_prompt.md | 24 -- codex-rs/exec/src/lib.rs | 1 - codex-rs/mcp-server/src/codex_tool_config.rs | 1 - codex-rs/mcp-server/src/codex_tool_runner.rs | 2 - codex-rs/mcp-server/src/exec_approval.rs | 5 - codex-rs/mcp-server/tests/suite/codex_tool.rs | 1 - codex-rs/otel/src/otel_event_manager.rs | 47 --- codex-rs/protocol/src/approvals.rs | 27 -- codex-rs/protocol/src/protocol.rs | 2 - .../tui/src/bottom_pane/approval_overlay.rs | 36 --- codex-rs/tui/src/bottom_pane/mod.rs | 1 - codex-rs/tui/src/chatwidget.rs | 1 - ...hatwidget__tests__approval_modal_exec.snap | 2 + ...roval_history_decision_approved_short.snap | 1 - codex-rs/tui/src/chatwidget/tests.rs | 6 - codex-rs/tui/src/lib.rs | 1 - docs/config.md | 21 +- docs/example-config.md | 2 - 40 files changed, 13 insertions(+), 703 deletions(-) delete mode 100644 codex-rs/core/src/sandboxing/assessment.rs delete mode 100644 codex-rs/core/templates/sandboxing/assessment_prompt.md diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bca96ff6313..8ee790f676a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -238,48 +238,6 @@ dependencies = [ "term", ] -[[package]] -name = "askama" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" -dependencies = [ - "askama_derive", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 2.0.104", -] - -[[package]] -name = "askama_parser" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" -dependencies = [ - "memchr", - "serde", - "serde_derive", - "winnow", -] - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -557,15 +515,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - [[package]] name = "beef" version = "0.5.2" @@ -1137,7 +1086,6 @@ name = "codex-core" version = "0.0.0" dependencies = [ "anyhow", - "askama", "assert_cmd", "assert_matches", "async-channel", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a2521e3bdab..cdf55434fe3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -109,7 +109,6 @@ allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" arboard = { version = "3", features = ["wayland-data-control"] } -askama = "0.14" assert_cmd = "2" assert_matches = "1.5.0" async-channel = "2.3.1" diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bd9f6ddedfa..116a3c62dd4 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -654,7 +654,6 @@ mod tests { command: vec!["echo".to_string(), "hello".to_string()], cwd: PathBuf::from("/tmp"), reason: Some("because tests".to_string()), - risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello".to_string(), }], @@ -674,7 +673,6 @@ mod tests { "command": ["echo", "hello"], "cwd": "/tmp", "reason": "because tests", - "risk": null, "parsedCmd": [ { "type": "unknown", diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 1576eb0d931..853cb03b405 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -13,7 +13,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::SandboxCommandAssessment; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; @@ -226,7 +225,6 @@ pub struct ExecCommandApprovalParams { pub command: Vec, pub cwd: PathBuf, pub reason: Option, - pub risk: Option, pub parsed_cmd: Vec, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index db987e27df0..211f0ba3757 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; -use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; @@ -275,14 +274,6 @@ pub struct ConfigEdit { pub merge_strategy: MergeStrategy, } -v2_enum_from_core!( - pub enum CommandRiskLevel from codex_protocol::approvals::SandboxRiskLevel { - Low, - Medium, - High - } -); - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -362,32 +353,6 @@ impl From for SandboxPolicy { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SandboxCommandAssessment { - pub description: String, - pub risk_level: CommandRiskLevel, -} - -impl SandboxCommandAssessment { - pub fn into_core(self) -> CoreSandboxCommandAssessment { - CoreSandboxCommandAssessment { - description: self.description, - risk_level: self.risk_level.to_core(), - } - } -} - -impl From for SandboxCommandAssessment { - fn from(value: CoreSandboxCommandAssessment) -> Self { - Self { - description: value.description, - risk_level: CommandRiskLevel::from(value.risk_level), - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(transparent)] #[ts(type = "Array", export_to = "v2/")] @@ -1535,8 +1500,6 @@ pub struct CommandExecutionRequestApprovalParams { pub item_id: String, /// Optional explanatory reason (e.g. request for network access). pub reason: Option, - /// Optional model-provided risk assessment describing the blocked command. - pub risk: Option, /// Optional proposed execpolicy amendment to allow similar commands without prompting. pub proposed_execpolicy_amendment: Option, } diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 924740896e8..b66c59d55a7 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -756,7 +756,6 @@ impl CodexClient { turn_id, item_id, reason, - risk, proposed_execpolicy_amendment, } = params; @@ -766,9 +765,6 @@ impl CodexClient { if let Some(reason) = reason.as_deref() { println!("< reason: {reason}"); } - if let Some(risk) = risk.as_ref() { - println!("< risk assessment: {risk:?}"); - } if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() { println!("< proposed execpolicy amendment: {execpolicy_amendment:?}"); } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 8956aedd138..b0161cd9fd1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ReasoningTextDeltaNotification; -use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::TerminalInteractionNotification; @@ -180,7 +179,6 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, - risk, proposed_execpolicy_amendment, parsed_cmd, }) => match api_version { @@ -191,7 +189,6 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, - risk, parsed_cmd, }; let rx = outgoing @@ -219,7 +216,6 @@ pub(crate) async fn apply_bespoke_event_handling( // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. item_id: item_id.clone(), reason, - risk: risk.map(V2SandboxCommandAssessment::from), proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, }; let rx = outgoing @@ -1214,7 +1210,7 @@ async fn construct_mcp_tool_call_notification( } } -/// simiilar to handle_mcp_tool_call_end in exec +/// similar to handle_mcp_tool_call_end in exec async fn construct_mcp_tool_call_end_notification( end_event: McpToolCallEndEvent, thread_id: String, diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 4b206436c86..e417198994d 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -271,7 +271,6 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { command: format_with_current_shell("python3 -c 'print(42)'"), cwd: working_directory.clone(), reason: None, - risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "python3 -c 'print(42)'".to_string() }], diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2bc281d903f..a11e7c24d60 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] anyhow = { workspace = true } -askama = { workspace = true } async-channel = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4129bb6a1f0..6f637d143c7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -95,7 +95,6 @@ use crate::protocol::RateLimitSnapshot; use crate::protocol::ReasoningContentDeltaEvent; use crate::protocol::ReasoningRawContentDeltaEvent; use crate::protocol::ReviewDecision; -use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; use crate::protocol::StreamErrorEvent; @@ -875,34 +874,6 @@ impl Session { .await; } - pub(crate) async fn assess_sandbox_command( - &self, - turn_context: &TurnContext, - call_id: &str, - command: &[String], - failure_message: Option<&str>, - ) -> Option { - let config = turn_context.client.config(); - let provider = turn_context.client.provider().clone(); - let auth_manager = Arc::clone(&self.services.auth_manager); - let otel = self.services.otel_event_manager.clone(); - crate::sandboxing::assessment::assess_command( - config, - provider, - auth_manager, - &otel, - self.conversation_id, - self.services.models_manager.clone(), - turn_context.client.get_session_source(), - call_id, - command, - &turn_context.sandbox_policy, - &turn_context.cwd, - failure_message, - ) - .await - } - /// Adds an execpolicy amendment to both the in-memory and on-disk policies so future /// commands can use the newly approved prefix. pub(crate) async fn persist_execpolicy_amendment( @@ -950,7 +921,6 @@ impl Session { command: Vec, cwd: PathBuf, reason: Option, - risk: Option, proposed_execpolicy_amendment: Option, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); @@ -978,7 +948,6 @@ impl Session { command, cwd, reason, - risk, proposed_execpolicy_amendment, parsed_cmd, }); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 670225ead06..75b29eddeed 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -280,7 +280,6 @@ async fn handle_exec_approval( event.command, event.cwd, event.reason, - event.risk, event.proposed_execpolicy_amendment, ); let decision = await_approval_with_cancel( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8db08c55a28..bdf7a541772 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -246,9 +246,6 @@ pub struct Config { pub tools_web_search_request: bool, - /// When `true`, run a model-based assessment for commands denied by the sandbox. - pub experimental_sandbox_command_assessment: bool, - /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -733,7 +730,6 @@ pub struct ConfigToml { pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, - pub experimental_sandbox_command_assessment: Option, /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". pub oss_provider: Option, } @@ -919,7 +915,6 @@ pub struct ConfigOverrides { pub include_apply_patch_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, - pub experimental_sandbox_command_assessment: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -978,7 +973,6 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, - experimental_sandbox_command_assessment: sandbox_command_assessment_override, additional_writable_roots, } = overrides; @@ -1003,7 +997,6 @@ impl Config { let feature_overrides = FeatureOverrides { include_apply_patch_tool: include_apply_patch_tool_override, web_search_request: override_tools_web_search_request, - experimental_sandbox_command_assessment: sandbox_command_assessment_override, }; let features = Features::from_config(&cfg, &config_profile, feature_overrides); @@ -1102,8 +1095,6 @@ impl Config { let tools_web_search_request = features.enabled(Feature::WebSearchRequest); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient); - let experimental_sandbox_command_assessment = - features.enabled(Feature::SandboxCommandAssessment); let forced_chatgpt_workspace_id = cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { @@ -1234,7 +1225,6 @@ impl Config { forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, tools_web_search_request, - experimental_sandbox_command_assessment, use_experimental_unified_exec_tool, use_experimental_use_rmcp_client, features, @@ -2990,7 +2980,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3065,7 +3054,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3155,7 +3143,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3231,7 +3218,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 5629465c404..978e1fcb639 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -27,7 +27,6 @@ pub struct ConfigProfile { pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, - pub experimental_sandbox_command_assessment: Option, pub tools_web_search: Option, pub tools_view_image: Option, /// Optional feature toggles scoped to this profile. diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index d714f8e85e5..4b8370039a9 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -48,8 +48,6 @@ pub enum Feature { WebSearchRequest, /// Gate the execpolicy enforcement for shell/unified exec. ExecPolicy, - /// Enable the model-based risk assessments for sandboxed commands. - SandboxCommandAssessment, /// Enable Windows sandbox (restricted token) on Windows. WindowsSandbox, /// Remote compaction enabled (only for ChatGPT auth) @@ -104,7 +102,6 @@ pub struct Features { pub struct FeatureOverrides { pub include_apply_patch_tool: Option, pub web_search_request: Option, - pub experimental_sandbox_command_assessment: Option, } impl FeatureOverrides { @@ -196,7 +193,6 @@ impl Features { let mut features = Features::with_defaults(); let base_legacy = LegacyFeatureToggles { - experimental_sandbox_command_assessment: cfg.experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, experimental_use_rmcp_client: cfg.experimental_use_rmcp_client, @@ -212,8 +208,6 @@ impl Features { let profile_legacy = LegacyFeatureToggles { include_apply_patch_tool: config_profile.include_apply_patch_tool, - experimental_sandbox_command_assessment: config_profile - .experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: config_profile .experimental_use_freeform_apply_patch, @@ -327,12 +321,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: true, }, - FeatureSpec { - id: Feature::SandboxCommandAssessment, - key: "experimental_sandbox_command_assessment", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::WindowsSandbox, key: "enable_experimental_windows_sandbox", diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 4d59f2a9a33..0c74d380e80 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -9,10 +9,6 @@ struct Alias { } const ALIASES: &[Alias] = &[ - Alias { - legacy_key: "experimental_sandbox_command_assessment", - feature: Feature::SandboxCommandAssessment, - }, Alias { legacy_key: "experimental_use_unified_exec_tool", feature: Feature::UnifiedExec, @@ -48,7 +44,6 @@ pub(crate) fn feature_for_key(key: &str) -> Option { #[derive(Debug, Default)] pub struct LegacyFeatureToggles { pub include_apply_patch_tool: Option, - pub experimental_sandbox_command_assessment: Option, pub experimental_use_freeform_apply_patch: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, @@ -64,12 +59,6 @@ impl LegacyFeatureToggles { self.include_apply_patch_tool, "include_apply_patch_tool", ); - set_if_some( - features, - Feature::SandboxCommandAssessment, - self.experimental_sandbox_command_assessment, - "experimental_sandbox_command_assessment", - ); set_if_some( features, Feature::ApplyPatchFreeform, diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs deleted file mode 100644 index b7a9c952d19..00000000000 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ /dev/null @@ -1,268 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; - -use crate::AuthManager; -use crate::ModelProviderInfo; -use crate::client::ModelClient; -use crate::client_common::Prompt; -use crate::client_common::ResponseEvent; -use crate::config::Config; -use crate::openai_models::models_manager::ModelsManager; -use crate::protocol::SandboxPolicy; -use askama::Template; -use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::ConversationId; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::SandboxCommandAssessment; -use codex_protocol::protocol::SessionSource; -use futures::StreamExt; -use serde_json::json; -use tokio::time::timeout; -use tracing::warn; - -const SANDBOX_ASSESSMENT_TIMEOUT: Duration = Duration::from_secs(15); -const SANDBOX_ASSESSMENT_REASONING_EFFORT: ReasoningEffortConfig = ReasoningEffortConfig::Medium; - -#[derive(Template)] -#[template(path = "sandboxing/assessment_prompt.md", escape = "none")] -struct SandboxAssessmentPromptTemplate<'a> { - platform: &'a str, - sandbox_policy: &'a str, - filesystem_roots: Option<&'a str>, - working_directory: &'a str, - command_argv: &'a str, - command_joined: &'a str, - sandbox_failure_message: Option<&'a str>, -} - -#[allow(clippy::too_many_arguments)] -pub(crate) async fn assess_command( - config: Arc, - provider: ModelProviderInfo, - auth_manager: Arc, - parent_otel: &OtelEventManager, - conversation_id: ConversationId, - models_manager: Arc, - session_source: SessionSource, - call_id: &str, - command: &[String], - sandbox_policy: &SandboxPolicy, - cwd: &Path, - failure_message: Option<&str>, -) -> Option { - if !config.experimental_sandbox_command_assessment || command.is_empty() { - return None; - } - - let command_json = serde_json::to_string(command).unwrap_or_else(|_| "[]".to_string()); - let command_joined = - shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); - let failure = failure_message - .map(str::trim) - .filter(|msg| !msg.is_empty()) - .map(str::to_string); - - let cwd_str = cwd.to_string_lossy().to_string(); - let sandbox_summary = summarize_sandbox_policy(sandbox_policy); - let mut roots = sandbox_roots_for_prompt(sandbox_policy, cwd); - roots.sort(); - roots.dedup(); - - let platform = std::env::consts::OS; - let roots_formatted = roots.iter().map(|root| root.to_string_lossy().to_string()); - let filesystem_roots = match roots_formatted.collect::>() { - collected if collected.is_empty() => None, - collected => Some(collected.join(", ")), - }; - - let prompt_template = SandboxAssessmentPromptTemplate { - platform, - sandbox_policy: sandbox_summary.as_str(), - filesystem_roots: filesystem_roots.as_deref(), - working_directory: cwd_str.as_str(), - command_argv: command_json.as_str(), - command_joined: command_joined.as_str(), - sandbox_failure_message: failure.as_deref(), - }; - let rendered_prompt = match prompt_template.render() { - Ok(rendered) => rendered, - Err(err) => { - warn!("failed to render sandbox assessment prompt: {err}"); - return None; - } - }; - let (system_prompt_section, user_prompt_section) = match rendered_prompt.split_once("\n---\n") { - Some(split) => split, - None => { - warn!("rendered sandbox assessment prompt missing separator"); - return None; - } - }; - let system_prompt = system_prompt_section - .strip_prefix("System Prompt:\n") - .unwrap_or(system_prompt_section) - .trim() - .to_string(); - let user_prompt = user_prompt_section - .strip_prefix("User Prompt:\n") - .unwrap_or(user_prompt_section) - .trim() - .to_string(); - - let prompt = Prompt { - input: vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: user_prompt }], - }], - tools: Vec::new(), - parallel_tool_calls: false, - base_instructions_override: Some(system_prompt), - output_schema: Some(sandbox_assessment_schema()), - }; - - let model_family = models_manager - .construct_model_family(&config.model, &config) - .await; - - let child_otel = parent_otel.with_model(config.model.as_str(), model_family.slug.as_str()); - - let client = ModelClient::new( - Arc::clone(&config), - Some(auth_manager), - model_family, - child_otel, - provider, - Some(SANDBOX_ASSESSMENT_REASONING_EFFORT), - config.model_reasoning_summary, - conversation_id, - session_source, - ); - - let start = Instant::now(); - let assessment_result = timeout(SANDBOX_ASSESSMENT_TIMEOUT, async move { - let mut stream = client.stream(&prompt).await?; - let mut last_json: Option = None; - while let Some(event) = stream.next().await { - match event { - Ok(ResponseEvent::OutputItemDone(item)) => { - if let Some(text) = response_item_text(&item) { - last_json = Some(text); - } - } - Ok(ResponseEvent::RateLimits(_)) => {} - Ok(ResponseEvent::Completed { .. }) => break, - Ok(_) => continue, - Err(err) => return Err(err), - } - } - Ok(last_json) - }) - .await; - let duration = start.elapsed(); - parent_otel.sandbox_assessment_latency(call_id, duration); - - match assessment_result { - Ok(Ok(Some(raw))) => match serde_json::from_str::(raw.trim()) { - Ok(assessment) => { - parent_otel.sandbox_assessment( - call_id, - "success", - Some(assessment.risk_level), - duration, - ); - return Some(assessment); - } - Err(err) => { - warn!("failed to parse sandbox assessment JSON: {err}"); - parent_otel.sandbox_assessment(call_id, "parse_error", None, duration); - } - }, - Ok(Ok(None)) => { - warn!("sandbox assessment response did not include any message"); - parent_otel.sandbox_assessment(call_id, "no_output", None, duration); - } - Ok(Err(err)) => { - warn!("sandbox assessment failed: {err}"); - parent_otel.sandbox_assessment(call_id, "model_error", None, duration); - } - Err(_) => { - warn!("sandbox assessment timed out"); - parent_otel.sandbox_assessment(call_id, "timeout", None, duration); - } - } - - None -} - -fn summarize_sandbox_policy(policy: &SandboxPolicy) -> String { - match policy { - SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), - SandboxPolicy::ReadOnly => "read-only".to_string(), - SandboxPolicy::WorkspaceWrite { network_access, .. } => { - let network = if *network_access { - "network" - } else { - "no-network" - }; - format!("workspace-write (network_access={network})") - } - } -} - -fn sandbox_roots_for_prompt(policy: &SandboxPolicy, cwd: &Path) -> Vec { - let mut roots = vec![cwd.to_path_buf()]; - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { - roots.extend(writable_roots.iter().cloned()); - } - roots -} - -fn sandbox_assessment_schema() -> serde_json::Value { - json!({ - "type": "object", - "required": ["description", "risk_level"], - "properties": { - "description": { - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "risk_level": { - "type": "string", - "enum": ["low", "medium", "high"] - }, - }, - "additionalProperties": false - }) -} - -fn response_item_text(item: &ResponseItem) -> Option { - match item { - ResponseItem::Message { content, .. } => { - let mut buffers: Vec<&str> = Vec::new(); - for segment in content { - match segment { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - if !text.is_empty() { - buffers.push(text); - } - } - ContentItem::InputImage { .. } => {} - } - } - if buffers.is_empty() { - None - } else { - Some(buffers.join("\n")) - } - } - ResponseItem::FunctionCallOutput { output, .. } => Some(output.content.clone()), - _ => None, - } -} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 3f56ce3ae9f..5d719a79229 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -6,8 +6,6 @@ sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn environment. */ -pub mod assessment; - use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 4c34658fcd5..003c727610b 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -7,12 +7,10 @@ retry without sandbox on denial (no re‑approval thanks to caching). */ use crate::error::CodexErr; use crate::error::SandboxErr; -use crate::error::get_error_message_ui; use crate::exec::ExecToolCallOutput; use crate::sandboxing::SandboxManager; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::ToolCtx; @@ -43,7 +41,6 @@ impl ToolOrchestrator { ) -> Result where T: ToolRuntime, - Rq: ProvidesSandboxRetryData, { let otel = turn_ctx.client.get_otel_event_manager(); let otel_tn = &tool_ctx.tool_name; @@ -65,26 +62,11 @@ impl ToolOrchestrator { return Err(ToolError::Rejected(reason)); } ExecApprovalRequirement::NeedsApproval { reason, .. } => { - let mut risk = None; - - if let Some(metadata) = req.sandbox_retry_data() { - risk = tool_ctx - .session - .assess_sandbox_command( - turn_ctx, - &tool_ctx.call_id, - &metadata.command, - None, - ) - .await; - } - let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: reason, - risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; @@ -141,33 +123,12 @@ impl ToolOrchestrator { // Ask for approval before retrying without sandbox. if !tool.should_bypass_approval(approval_policy, already_approved) { - let mut risk = None; - - if let Some(metadata) = req.sandbox_retry_data() { - let err = SandboxErr::Denied { - output: output.clone(), - }; - let friendly = get_error_message_ui(&CodexErr::Sandbox(err)); - let failure_summary = format!("failed in sandbox: {friendly}"); - - risk = tool_ctx - .session - .assess_sandbox_command( - turn_ctx, - &tool_ctx.call_id, - &metadata.command, - Some(failure_summary.as_str()), - ) - .await; - } - let reason_msg = build_denial_reason_from_output(output.as_ref()); let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: Some(reason_msg), - risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index bf4b66ce9d3..26d04f578c5 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -11,9 +11,7 @@ use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -35,12 +33,6 @@ pub struct ApplyPatchRequest { pub codex_exe: Option, } -impl ProvidesSandboxRetryData for ApplyPatchRequest { - fn sandbox_retry_data(&self) -> Option { - None - } -} - #[derive(Default)] pub struct ApplyPatchRuntime; @@ -115,7 +107,6 @@ impl Approvable for ApplyPatchRuntime { let call_id = ctx.call_id.to_string(); let cwd = req.cwd.clone(); let retry_reason = ctx.retry_reason.clone(); - let risk = ctx.risk.clone(); let user_explicitly_approved = req.user_explicitly_approved; Box::pin(async move { with_cached_approval(&session.services, key, move || async move { @@ -127,7 +118,6 @@ impl Approvable for ApplyPatchRuntime { vec!["apply_patch".to_string()], cwd, Some(reason), - risk, None, ) .await diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 595bda0e967..078be68e890 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -11,10 +11,8 @@ use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -36,15 +34,6 @@ pub struct ShellRequest { pub exec_approval_requirement: ExecApprovalRequirement, } -impl ProvidesSandboxRetryData for ShellRequest { - fn sandbox_retry_data(&self) -> Option { - Some(SandboxRetryData { - command: self.command.clone(), - cwd: self.cwd.clone(), - }) - } -} - #[derive(Default)] pub struct ShellRuntime; @@ -101,7 +90,6 @@ impl Approvable for ShellRuntime { .retry_reason .clone() .or_else(|| req.justification.clone()); - let risk = ctx.risk.clone(); let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); @@ -114,7 +102,6 @@ impl Approvable for ShellRuntime { command, cwd, reason, - risk, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index b6a8047080f..3d35987c7e2 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -12,10 +12,8 @@ use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -40,15 +38,6 @@ pub struct UnifiedExecRequest { pub exec_approval_requirement: ExecApprovalRequirement, } -impl ProvidesSandboxRetryData for UnifiedExecRequest { - fn sandbox_retry_data(&self) -> Option { - Some(SandboxRetryData { - command: self.command.clone(), - cwd: self.cwd.clone(), - }) - } -} - #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { pub command: Vec, @@ -122,7 +111,6 @@ impl Approvable for UnifiedExecRuntime<'_> { .retry_reason .clone() .or_else(|| req.justification.clone()); - let risk = ctx.risk.clone(); Box::pin(async move { with_cached_approval(&session.services, key, || async move { session @@ -132,7 +120,6 @@ impl Approvable for UnifiedExecRuntime<'_> { command, cwd, reason, - risk, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 5e69696923d..96bc633c584 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -7,7 +7,6 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::error::CodexErr; -use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; @@ -20,7 +19,6 @@ use std::collections::HashMap; use std::fmt::Debug; use std::hash::Hash; use std::path::Path; -use std::path::PathBuf; use futures::Future; use futures::future::BoxFuture; @@ -84,7 +82,6 @@ pub(crate) struct ApprovalCtx<'a> { pub turn: &'a TurnContext, pub call_id: &'a str, pub retry_reason: Option, - pub risk: Option, } // Specifies what tool orchestrator should do with a given tool call. @@ -219,17 +216,6 @@ pub(crate) struct ToolCtx<'a> { pub tool_name: String, } -/// Captures the command metadata needed to re-run a tool request without sandboxing. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct SandboxRetryData { - pub command: Vec, - pub cwd: PathBuf, -} - -pub(crate) trait ProvidesSandboxRetryData { - fn sandbox_retry_data(&self) -> Option; -} - #[derive(Debug)] pub(crate) enum ToolError { Rejected(String), diff --git a/codex-rs/core/templates/sandboxing/assessment_prompt.md b/codex-rs/core/templates/sandboxing/assessment_prompt.md deleted file mode 100644 index dc59f47d7cc..00000000000 --- a/codex-rs/core/templates/sandboxing/assessment_prompt.md +++ /dev/null @@ -1,24 +0,0 @@ -You are a security analyst evaluating shell commands that were blocked by a sandbox. Given the provided metadata, summarize the command's likely intent and assess the risk to help the user decide whether to approve command execution. Return strictly valid JSON with the keys: -- description (concise summary of command intent and potential effects, no more than one sentence, use present tense) -- risk_level ("low", "medium", or "high") -Risk level examples: -- low: read-only inspections, listing files, printing configuration, fetching artifacts from trusted sources -- medium: modifying project files, installing dependencies -- high: deleting or overwriting data, exfiltrating secrets, escalating privileges, or disabling security controls -If information is insufficient, choose the most cautious risk level supported by the evidence. -Respond with JSON only, without markdown code fences or extra commentary. - ---- - -Command metadata: -Platform: {{ platform }} -Sandbox policy: {{ sandbox_policy }} -{% if let Some(roots) = filesystem_roots %} -Filesystem roots: {{ roots }} -{% endif %} -Working directory: {{ working_directory }} -Command argv: {{ command_argv }} -Command (joined): {{ command_joined }} -{% if let Some(message) = sandbox_failure_message %} -Sandbox failure message: {{ message }} -{% endif %} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0cf5aaf7882..7dfeeecf7b9 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -200,7 +200,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_apply_patch_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: add_dir, }; diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 4e61bde02b8..feadf0add65 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -169,7 +169,6 @@ impl CodexToolCallParam { include_apply_patch_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), }; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 908cba1cc38..d39a38cde94 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -179,7 +179,6 @@ async fn run_codex_tool_session_inner( cwd, call_id, reason: _, - risk, proposed_execpolicy_amendment: _, parsed_cmd, }) => { @@ -193,7 +192,6 @@ async fn run_codex_tool_session_inner( event.id.clone(), call_id, parsed_cmd, - risk, ) .await; continue; diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 033523ac0df..44607b754d7 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use codex_core::CodexConversation; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; -use codex_core::protocol::SandboxCommandAssessment; use codex_protocol::parse_command::ParsedCommand; use mcp_types::ElicitRequest; use mcp_types::ElicitRequestParamsRequestedSchema; @@ -38,8 +37,6 @@ pub struct ExecApprovalElicitRequestParams { pub codex_command: Vec, pub codex_cwd: PathBuf, pub codex_parsed_cmd: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub codex_risk: Option, } // TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See: @@ -62,7 +59,6 @@ pub(crate) async fn handle_exec_approval_request( event_id: String, call_id: String, codex_parsed_cmd: Vec, - codex_risk: Option, ) { let escaped_command = shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); @@ -85,7 +81,6 @@ pub(crate) async fn handle_exec_approval_request( codex_command: command, codex_cwd: cwd, codex_parsed_cmd, - codex_risk, }; let params_json = match serde_json::to_value(¶ms) { Ok(value) => value, diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index f65495c473a..d0a78ae3927 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -200,7 +200,6 @@ fn create_expected_elicitation_request( codex_cwd: workdir.to_path_buf(), codex_call_id: "call1234".to_string(), codex_parsed_cmd, - codex_risk: None, })?), }) } diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index d3536cd8db2..54e3fe3dc18 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -8,7 +8,6 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SandboxRiskLevel; use codex_protocol::user_input::UserInput; use eventsource_stream::Event as StreamEvent; use eventsource_stream::EventStreamError as StreamError; @@ -374,52 +373,6 @@ impl OtelEventManager { ); } - pub fn sandbox_assessment( - &self, - call_id: &str, - status: &str, - risk_level: Option, - duration: Duration, - ) { - let level = risk_level.map(|level| level.as_str()); - - tracing::event!( - tracing::Level::INFO, - event.name = "codex.sandbox_assessment", - event.timestamp = %timestamp(), - conversation.id = %self.metadata.conversation_id, - app.version = %self.metadata.app_version, - auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, - user.email = self.metadata.account_email, - terminal.type = %self.metadata.terminal_type, - model = %self.metadata.model, - slug = %self.metadata.slug, - call_id = %call_id, - status = %status, - risk_level = level, - duration_ms = %duration.as_millis(), - ); - } - - pub fn sandbox_assessment_latency(&self, call_id: &str, duration: Duration) { - tracing::event!( - tracing::Level::INFO, - event.name = "codex.sandbox_assessment_latency", - event.timestamp = %timestamp(), - conversation.id = %self.metadata.conversation_id, - app.version = %self.metadata.app_version, - auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, - user.email = self.metadata.account_email, - terminal.type = %self.metadata.terminal_type, - model = %self.metadata.model, - slug = %self.metadata.slug, - call_id = %call_id, - duration_ms = %duration.as_millis(), - ); - } - pub async fn log_tool_result( &self, tool_name: &str, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index c892b6ec991..78050dfa860 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -9,14 +9,6 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -pub enum SandboxRiskLevel { - Low, - Medium, - High, -} - /// Proposed execpolicy change to allow commands starting with this prefix. /// /// The `command` tokens form the prefix that would be added as an execpolicy @@ -45,22 +37,6 @@ impl From> for ExecPolicyAmendment { } } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] -pub struct SandboxCommandAssessment { - pub description: String, - pub risk_level: SandboxRiskLevel, -} - -impl SandboxRiskLevel { - pub fn as_str(&self) -> &'static str { - match self { - Self::Low => "low", - Self::Medium => "medium", - Self::High => "high", - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. @@ -76,9 +52,6 @@ pub struct ExecApprovalRequestEvent { /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, - /// Optional model-provided risk assessment describing the blocked command. - #[serde(skip_serializing_if = "Option::is_none")] - pub risk: Option, /// Proposed execpolicy amendment that can be applied to allow future runs. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1e40618f01c..973fd265821 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -40,8 +40,6 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; -pub use crate::approvals::SandboxCommandAssessment; -pub use crate::approvals::SandboxRiskLevel; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 768fe030d4c..d42861eb1d5 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -23,8 +23,6 @@ use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; -use codex_core::protocol::SandboxCommandAssessment; -use codex_core::protocol::SandboxRiskLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -45,7 +43,6 @@ pub(crate) enum ApprovalRequest { id: String, command: Vec, reason: Option, - risk: Option, proposed_execpolicy_amendment: Option, }, ApplyPatch { @@ -345,18 +342,11 @@ impl From for ApprovalRequestState { id, command, reason, - risk, proposed_execpolicy_amendment, } => { - let reason = reason.filter(|item| !item.is_empty()); - let has_reason = reason.is_some(); let mut header: Vec> = Vec::new(); if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); - } - if let Some(risk) = risk.as_ref() { - header.extend(render_risk_lines(risk)); - } else if has_reason { header.push(Line::from("")); } let full_cmd = strip_bash_lc_and_escape(&command); @@ -419,28 +409,6 @@ impl From for ApprovalRequestState { } } -fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec> { - let level_span = match risk.risk_level { - SandboxRiskLevel::Low => "LOW".green().bold(), - SandboxRiskLevel::Medium => "MEDIUM".cyan().bold(), - SandboxRiskLevel::High => "HIGH".red().bold(), - }; - - let mut lines = Vec::new(); - - let description = risk.description.trim(); - if !description.is_empty() { - lines.push(Line::from(vec![ - "Summary: ".into(), - description.to_string().into(), - ])); - } - - lines.push(vec!["Risk: ".into(), level_span].into()); - lines.push(Line::from("")); - lines -} - #[derive(Clone)] enum ApprovalVariant { Exec { @@ -570,7 +538,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), - risk: None, proposed_execpolicy_amendment: None, } } @@ -613,7 +580,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -652,7 +618,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -679,7 +644,6 @@ mod tests { id: "test".into(), command, reason: None, - risk: None, proposed_execpolicy_amendment: None, }; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8a4336f6fe8..554810de7f0 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -570,7 +570,6 @@ mod tests { id: "1".to_string(), command: vec!["echo".into(), "ok".into()], reason: None, - risk: None, proposed_execpolicy_amendment: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dd623ed5837..f9e53c80552 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1095,7 +1095,6 @@ impl ChatWidget { id, command: ev.command, reason: ev.reason, - risk: ev.risk, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; self.bottom_pane diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index ca093f271aa..15511611a10 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -2,6 +2,8 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend().vt100().screen().contents() --- + + Would you like to run the following command? Reason: this is a test reason such as one that would be produced by the model diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap index 1b18a23d4d2..2f0f1412a1f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -3,4 +3,3 @@ source: tui/src/chatwidget/tests.rs expression: lines_to_single_string(&decision) --- ✔ You approved codex to run echo hello world this time - diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fc1d25edfe7..23554932503 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -755,7 +755,6 @@ fn exec_approval_emits_proposed_command_and_decision_history() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -800,7 +799,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -851,7 +849,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -2105,7 +2102,6 @@ fn approval_modal_exec_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -2157,7 +2153,6 @@ fn approval_modal_exec_without_reason_snapshot() { command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -2376,7 +2371,6 @@ fn status_widget_and_approval_modal_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello world".into(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0aa422cc614..d9793a07a04 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -218,7 +218,6 @@ pub async fn run_main( include_apply_patch_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: additional_dirs, }; diff --git a/docs/config.md b/docs/config.md index 4e78da7ac66..8b131cd63a6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,17 +39,16 @@ web_search_request = true # allow the model to request web searches Supported features: -| Key | Default | Stage | Description | -| ----------------------------------------- | :-----: | ------------ | ----------------------------------------------------- | -| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | -| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | -| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | -| `view_image_tool` | true | Stable | Include the `view_image` tool | -| `web_search_request` | false | Stable | Allow the model to issue web searches | -| `experimental_sandbox_command_assessment` | false | Experimental | Enable model-based sandbox risk assessment | -| `ghost_commit` | false | Experimental | Create a ghost commit each turn | -| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | -| `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation | +| Key | Default | Stage | Description | +| ------------------------------------- | :-----: | ------------ | ----------------------------------------------------- | +| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | +| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | +| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | +| `view_image_tool` | true | Stable | Include the `view_image` tool | +| `web_search_request` | false | Stable | Allow the model to issue web searches | +| `ghost_commit` | false | Experimental | Create a ghost commit each turn | +| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | +| `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation | Notes: diff --git a/docs/example-config.md b/docs/example-config.md index 1f326ac14b8..2345274e1b5 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -218,7 +218,6 @@ rmcp_client = false apply_patch_freeform = false view_image_tool = true web_search_request = false -experimental_sandbox_command_assessment = false ghost_commit = false enable_experimental_windows_sandbox = false @@ -314,7 +313,6 @@ experimental_use_freeform_apply_patch = false # experimental_compact_prompt_file = "compact_prompt.txt" # include_apply_patch_tool = false # experimental_use_freeform_apply_patch = false -# experimental_sandbox_command_assessment = false # tools_web_search = false # tools_view_image = true # features = { unified_exec = false } From f677d05871b49628d62cd289e5fa2d1b5aa7af36 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 17:57:53 +0000 Subject: [PATCH 70/94] fix: flaky tests 3 (#7826) --- codex-rs/core/tests/suite/approvals.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 879ad56d479..10a510af427 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -126,7 +126,7 @@ impl ActionKind { ); let command = format!("python3 -c \"{script}\""); - let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; + let event = shell_event(call_id, &command, 3_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::RunCommand { command } => { From bd51d1b10383f4cc28d5228c29e6726935800c7c Mon Sep 17 00:00:00 2001 From: Amit Halfon Date: Wed, 10 Dec 2025 20:17:00 +0200 Subject: [PATCH 71/94] fix: Upgrade @modelcontextprotocol/sdk to ^1.24.0 (#7817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Upgrades @modelcontextprotocol/sdk from ^1.20.2 to ^1.24.0 in the TypeScript SDK's devDependencies. ## Why? Related to #7737 - keeping development dependencies up to date with the latest MCP SDK version that includes the fix for CVE-2025-66414. Note: This change does not address the CVE for Codex users, as the MCP SDK is only in devDependencies here. The actual MCP integration that would be affected by the CVE is in the Rust codebase. ## How? • Updated dependency version in sdk/typescript/package.json • Ran pnpm install to update lockfile • Fixed formatting (added missing newline in package.json) ## Related Issue Related to #7737 ## Test Status ⚠️ After this upgrade, 2 additional tests timeout (1 test was already failing on main): • tests/run.test.ts: "sends previous items when run is called twice" • tests/run.test.ts: "resumes thread by id" • tests/runStreamed.test.ts: "sends previous items when runStreamed is called twice" Marking as draft to investigate test timeouts. Maintainer guidance would be appreciated. Co-authored-by: HalfonA --- pnpm-lock.yaml | 74 +++++++++++++++++++++++++++++++++---- sdk/typescript/package.json | 2 +- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03d88bce45a..01112d6d3bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: sdk/typescript: devDependencies: '@modelcontextprotocol/sdk': - specifier: ^1.20.2 - version: 1.20.2 + specifier: ^1.24.0 + version: 1.24.3(zod@3.25.76) '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -573,9 +573,15 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@modelcontextprotocol/sdk@1.20.2': - resolution: {integrity: sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==} + '@modelcontextprotocol/sdk@1.24.3': + resolution: {integrity: sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw==} engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -846,9 +852,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1294,6 +1311,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1677,6 +1697,9 @@ packages: node-notifier: optional: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1706,6 +1729,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2053,6 +2079,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -2476,6 +2506,11 @@ packages: peerDependencies: zod: ^3.24.1 + zod-to-json-schema@3.25.0: + resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} + peerDependencies: + zod: ^3.25 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -3012,9 +3047,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.20.2': + '@modelcontextprotocol/sdk@1.24.3(zod@3.25.76)': dependencies: - ajv: 6.12.6 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 cors: 2.8.5 cross-spawn: 7.0.6 @@ -3022,10 +3058,11 @@ snapshots: eventsource-parser: 3.0.6 express: 5.1.0 express-rate-limit: 7.5.1(express@5.1.0) + jose: 6.1.3 pkce-challenge: 5.0.0 raw-body: 3.0.1 zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.0(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -3292,6 +3329,10 @@ snapshots: acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3299,6 +3340,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -3795,6 +3843,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -4367,6 +4417,8 @@ snapshots: - supports-color - ts-node + jose@6.1.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -4388,6 +4440,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -4670,6 +4724,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -5116,4 +5172,8 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.0(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index b5a6ce82c3a..55ecd1abf38 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -45,7 +45,7 @@ "prepare": "pnpm run build" }, "devDependencies": { - "@modelcontextprotocol/sdk": "^1.20.2", + "@modelcontextprotocol/sdk": "^1.24.0", "@types/jest": "^29.5.14", "@types/node": "^20.19.18", "eslint": "^9.36.0", From 9f40d6eeebebbee316bca6eac8e7f312a9c1d0b8 Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Thu, 11 Dec 2025 03:23:01 +0900 Subject: [PATCH 72/94] fix: remove duplicated parallel FeatureSpec (#7823) regression: #7589 Signed-off-by: Koichi Shiraishi --- codex-rs/core/src/features.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 4b8370039a9..a011884fc45 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -339,12 +339,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, - FeatureSpec { - id: Feature::ParallelToolCalls, - key: "parallel", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::Skills, key: "skills", From 4b684c53aea9a4a0573a7ffd3530eac71a670a04 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 10 Dec 2025 10:44:12 -0800 Subject: [PATCH 73/94] Remove conversation_id and bring back request ID logging (#7830) --- codex-rs/Cargo.lock | 1 + codex-rs/codex-client/src/default_client.rs | 143 ++++++++++++++++++++ codex-rs/codex-client/src/lib.rs | 3 + codex-rs/codex-client/src/transport.rs | 10 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/auth.rs | 2 +- codex-rs/core/src/default_client.rs | 134 +----------------- 7 files changed, 159 insertions(+), 135 deletions(-) create mode 100644 codex-rs/codex-client/src/default_client.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8ee790f676a..df26fcfc938 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1098,6 +1098,7 @@ dependencies = [ "codex-apply-patch", "codex-arg0", "codex-async-utils", + "codex-client", "codex-core", "codex-execpolicy", "codex-file-search", diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/codex-client/src/default_client.rs new file mode 100644 index 00000000000..8a25846385a --- /dev/null +++ b/codex-rs/codex-client/src/default_client.rs @@ -0,0 +1,143 @@ +use http::Error as HttpError; +use reqwest::IntoUrl; +use reqwest::Method; +use reqwest::Response; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt::Display; +use std::time::Duration; + +#[derive(Clone, Debug)] +pub struct CodexHttpClient { + inner: reqwest::Client, +} + +impl CodexHttpClient { + pub fn new(inner: reqwest::Client) -> Self { + Self { inner } + } + + pub fn get(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::GET, url) + } + + pub fn post(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::POST, url) + } + + pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + let url_str = url.as_str().to_string(); + CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) + } +} + +#[must_use = "requests are not sent unless `send` is awaited"] +#[derive(Debug)] +pub struct CodexRequestBuilder { + builder: reqwest::RequestBuilder, + method: Method, + url: String, +} + +impl CodexRequestBuilder { + fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { + Self { + builder, + method, + url, + } + } + + fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { + Self { + builder: f(self.builder), + method: self.method, + url: self.url, + } + } + + pub fn headers(self, headers: HeaderMap) -> Self { + self.map(|builder| builder.headers(headers)) + } + + pub fn header(self, key: K, value: V) -> Self + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + self.map(|builder| builder.header(key, value)) + } + + pub fn bearer_auth(self, token: T) -> Self + where + T: Display, + { + self.map(|builder| builder.bearer_auth(token)) + } + + pub fn timeout(self, timeout: Duration) -> Self { + self.map(|builder| builder.timeout(timeout)) + } + + pub fn json(self, value: &T) -> Self + where + T: ?Sized + Serialize, + { + self.map(|builder| builder.json(value)) + } + + pub async fn send(self) -> Result { + match self.builder.send().await { + Ok(response) => { + let request_ids = Self::extract_request_ids(&response); + tracing::debug!( + method = %self.method, + url = %self.url, + status = %response.status(), + request_ids = ?request_ids, + version = ?response.version(), + "Request completed" + ); + + Ok(response) + } + Err(error) => { + let status = error.status(); + tracing::debug!( + method = %self.method, + url = %self.url, + status = status.map(|s| s.as_u16()), + error = %error, + "Request failed" + ); + Err(error) + } + } + } + + fn extract_request_ids(response: &Response) -> HashMap { + ["cf-ray", "x-request-id", "x-oai-request-id"] + .iter() + .filter_map(|&name| { + let header_name = HeaderName::from_static(name); + let value = response.headers().get(header_name)?; + let value = value.to_str().ok()?.to_owned(); + Some((name.to_owned(), value)) + }) + .collect() + } +} diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 3ac00a21a8b..66d1083c07d 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -1,3 +1,4 @@ +mod default_client; mod error; mod request; mod retry; @@ -5,6 +6,8 @@ mod sse; mod telemetry; mod transport; +pub use crate::default_client::CodexHttpClient; +pub use crate::default_client::CodexRequestBuilder; pub use crate::error::StreamError; pub use crate::error::TransportError; pub use crate::request::Request; diff --git a/codex-rs/codex-client/src/transport.rs b/codex-rs/codex-client/src/transport.rs index 5edc9a7b779..986ba3a6792 100644 --- a/codex-rs/codex-client/src/transport.rs +++ b/codex-rs/codex-client/src/transport.rs @@ -1,3 +1,5 @@ +use crate::default_client::CodexHttpClient; +use crate::default_client::CodexRequestBuilder; use crate::error::TransportError; use crate::request::Request; use crate::request::Response; @@ -28,15 +30,17 @@ pub trait HttpTransport: Send + Sync { #[derive(Clone, Debug)] pub struct ReqwestTransport { - client: reqwest::Client, + client: CodexHttpClient, } impl ReqwestTransport { pub fn new(client: reqwest::Client) -> Self { - Self { client } + Self { + client: CodexHttpClient::new(client), + } } - fn build(&self, req: Request) -> Result { + fn build(&self, req: Request) -> Result { let mut builder = self .client .request( diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a11e7c24d60..4c231e4dda5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -23,6 +23,7 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } +codex-client = { workspace = true } codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 57ffa172607..20943982d4d 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -23,7 +23,6 @@ pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::config::Config; -use crate::default_client::CodexHttpClient; use crate::error::RefreshTokenFailedError; use crate::error::RefreshTokenFailedReason; use crate::token_data::KnownPlan as InternalKnownPlan; @@ -31,6 +30,7 @@ use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; +use codex_client::CodexHttpClient; use codex_protocol::account::PlanType as AccountPlanType; use once_cell::sync::Lazy; use serde_json::Value; diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 29986c401d5..7ae2f8c35ac 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -1,17 +1,12 @@ use crate::spawn::CODEX_SANDBOX_ENV_VAR; -use http::Error as HttpError; -use reqwest::IntoUrl; -use reqwest::Method; -use reqwest::Response; -use reqwest::header::HeaderName; use reqwest::header::HeaderValue; -use serde::Serialize; -use std::collections::HashMap; -use std::fmt::Display; use std::sync::LazyLock; use std::sync::Mutex; use std::sync::OnceLock; +use codex_client::CodexHttpClient; +pub use codex_client::CodexRequestBuilder; + /// Set this to add a suffix to the User-Agent string. /// /// It is not ideal that we're using a global singleton for this. @@ -31,129 +26,6 @@ pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; -#[derive(Clone, Debug)] -pub struct CodexHttpClient { - inner: reqwest::Client, -} - -impl CodexHttpClient { - fn new(inner: reqwest::Client) -> Self { - Self { inner } - } - - pub fn get(&self, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - self.request(Method::GET, url) - } - - pub fn post(&self, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - self.request(Method::POST, url) - } - - pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - let url_str = url.as_str().to_string(); - CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) - } -} - -#[must_use = "requests are not sent unless `send` is awaited"] -#[derive(Debug)] -pub struct CodexRequestBuilder { - builder: reqwest::RequestBuilder, - method: Method, - url: String, -} - -impl CodexRequestBuilder { - fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { - Self { - builder, - method, - url, - } - } - - fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { - Self { - builder: f(self.builder), - method: self.method, - url: self.url, - } - } - - pub fn header(self, key: K, value: V) -> Self - where - HeaderName: TryFrom, - >::Error: Into, - HeaderValue: TryFrom, - >::Error: Into, - { - self.map(|builder| builder.header(key, value)) - } - - pub fn bearer_auth(self, token: T) -> Self - where - T: Display, - { - self.map(|builder| builder.bearer_auth(token)) - } - - pub fn json(self, value: &T) -> Self - where - T: ?Sized + Serialize, - { - self.map(|builder| builder.json(value)) - } - - pub async fn send(self) -> Result { - match self.builder.send().await { - Ok(response) => { - let request_ids = Self::extract_request_ids(&response); - tracing::debug!( - method = %self.method, - url = %self.url, - status = %response.status(), - request_ids = ?request_ids, - version = ?response.version(), - "Request completed" - ); - - Ok(response) - } - Err(error) => { - let status = error.status(); - tracing::debug!( - method = %self.method, - url = %self.url, - status = status.map(|s| s.as_u16()), - error = %error, - "Request failed" - ); - Err(error) - } - } - } - - fn extract_request_ids(response: &Response) -> HashMap { - ["cf-ray", "x-request-id", "x-oai-request-id"] - .iter() - .filter_map(|&name| { - let header_name = HeaderName::from_static(name); - let value = response.headers().get(header_name)?; - let value = value.to_str().ok()?.to_owned(); - Some((name.to_owned(), value)) - }) - .collect() - } -} #[derive(Debug, Clone)] pub struct Originator { pub value: String, From 8a71f8b6348a4ff48a403615674b17fb890b320e Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 10 Dec 2025 11:14:27 -0800 Subject: [PATCH 74/94] [app-server] Make sure that config writes preserve comments & order or configs (#7789) Make sure that config writes preserve comments and order of configs by utilizing the ConfigEditsBuilder in core. Tested by running a real example and made sure that nothing in the config file changes other than the configs to edit. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/config_api.rs | 190 ++++++++++++++++++++++---- codex-rs/core/src/config/edit.rs | 26 ++++ 4 files changed, 189 insertions(+), 29 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index df26fcfc938..304343bf2c3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -851,6 +851,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "toml_edit", "tracing", "tracing-subscriber", "uuid", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index e4a326a2c3e..948facdea6f 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -35,6 +35,7 @@ sha2 = { workspace = true } mcp-types = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index ae02927f7af..98fe93fb259 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,6 +1,5 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; -use anyhow::anyhow; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -15,6 +14,8 @@ use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::OverriddenMetadata; use codex_app_server_protocol::WriteStatus; use codex_core::config::ConfigToml; +use codex_core::config::edit::ConfigEdit; +use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config_loader::LoadedConfigLayers; use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::load_config_layers_with_overrides; @@ -26,9 +27,8 @@ use sha2::Sha256; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use tempfile::NamedTempFile; -use tokio::task; use toml::Value as TomlValue; +use toml_edit::Item as TomlItem; const SESSION_FLAGS_SOURCE: &str = "--config"; const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64"; @@ -141,19 +141,20 @@ impl ConfigApi { } let mut user_config = layers.user.config.clone(); - let mut mutated = false; let mut parsed_segments = Vec::new(); + let mut config_edits = Vec::new(); for (key_path, value, strategy) in edits.into_iter() { let segments = parse_key_path(&key_path).map_err(|message| { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) })?; + let original_value = value_at_path(&user_config, &segments).cloned(); let parsed_value = parse_value(value).map_err(|message| { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) })?; - let changed = apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy) - .map_err(|err| match err { + apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err( + |err| match err { MergeError::PathNotFound => config_write_error( ConfigWriteErrorCode::ConfigPathNotFound, "Path not found", @@ -161,9 +162,24 @@ impl ConfigApi { MergeError::Validation(message) => { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) } - })?; + }, + )?; + + let updated_value = value_at_path(&user_config, &segments).cloned(); + if original_value != updated_value { + let edit = match updated_value { + Some(value) => ConfigEdit::SetPath { + segments: segments.clone(), + value: toml_value_to_item(&value) + .map_err(|err| internal_error("failed to build config edits", err))?, + }, + None => ConfigEdit::ClearPath { + segments: segments.clone(), + }, + }; + config_edits.push(edit); + } - mutated |= changed; parsed_segments.push(segments); } @@ -183,8 +199,10 @@ impl ConfigApi { ) })?; - if mutated { - self.persist_user_config(&user_config) + if !config_edits.is_empty() { + ConfigEditsBuilder::new(&self.codex_home) + .with_edits(config_edits) + .apply() .await .map_err(|err| internal_error("failed to persist config.toml", err))?; } @@ -253,25 +271,6 @@ impl ConfigApi { mdm, }) } - - async fn persist_user_config(&self, user_config: &TomlValue) -> anyhow::Result<()> { - let codex_home = self.codex_home.clone(); - let serialized = toml::to_string_pretty(user_config)?; - - task::spawn_blocking(move || -> anyhow::Result<()> { - std::fs::create_dir_all(&codex_home)?; - - let target = codex_home.join(CONFIG_FILE_NAME); - let tmp = NamedTempFile::new_in(&codex_home)?; - std::fs::write(tmp.path(), serialized.as_bytes())?; - tmp.persist(&target)?; - Ok(()) - }) - .await - .map_err(|err| anyhow!("config persistence task panicked: {err}"))??; - - Ok(()) - } } fn parse_value(value: JsonValue) -> Result, String> { @@ -422,6 +421,44 @@ fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result anyhow::Result { + match value { + TomlValue::Table(table) => { + let mut table_item = toml_edit::Table::new(); + table_item.set_implicit(false); + for (key, val) in table { + table_item.insert(key, toml_value_to_item(val)?); + } + Ok(TomlItem::Table(table_item)) + } + other => Ok(TomlItem::Value(toml_value_to_value(other)?)), + } +} + +fn toml_value_to_value(value: &TomlValue) -> anyhow::Result { + match value { + TomlValue::String(val) => Ok(toml_edit::Value::from(val.clone())), + TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Array(items) => { + let mut array = toml_edit::Array::new(); + for item in items { + array.push(toml_value_to_value(item)?); + } + Ok(toml_edit::Value::Array(array)) + } + TomlValue::Table(table) => { + let mut inline = toml_edit::InlineTable::new(); + for (key, val) in table { + inline.insert(key, toml_value_to_value(val)?); + } + Ok(toml_edit::Value::InlineTable(inline)) + } + } +} + #[derive(Clone)] struct LayerState { name: ConfigLayerName, @@ -735,9 +772,104 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::*; + use anyhow::Result; use pretty_assertions::assert_eq; use tempfile::tempdir; + #[test] + fn toml_value_to_item_handles_nested_config_tables() { + let config = r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.http_headers] +X-Doc = "42" +"#; + + let value: TomlValue = toml::from_str(config).expect("parse config example"); + let item = toml_value_to_item(&value).expect("convert to toml_edit item"); + + let root = item.as_table().expect("root table"); + assert!(!root.is_implicit(), "root table should be explicit"); + + let mcp_servers = root + .get("mcp_servers") + .and_then(TomlItem::as_table) + .expect("mcp_servers table"); + assert!( + !mcp_servers.is_implicit(), + "mcp_servers table should be explicit" + ); + + let docs = mcp_servers + .get("docs") + .and_then(TomlItem::as_table) + .expect("docs table"); + assert_eq!( + docs.get("command") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("docs-server") + ); + + let http_headers = docs + .get("http_headers") + .and_then(TomlItem::as_table) + .expect("http_headers table"); + assert_eq!( + http_headers + .get("X-Doc") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("42") + ); + } + + #[tokio::test] + async fn write_value_preserves_comments_and_order() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let original = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +"#; + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), original)?; + + let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); + api.write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), + key_path: "features.remote_compaction".to_string(), + value: json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let updated = + std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); + let expected = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +remote_compaction = true +"#; + assert_eq!(updated, expected); + Ok(()) + } + #[tokio::test] async fn read_includes_origins_and_layers() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 68e2d206f0d..37c2aba6efd 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -555,6 +555,14 @@ impl ConfigEditsBuilder { self } + pub fn with_edits(mut self, edits: I) -> Self + where + I: IntoIterator, + { + self.edits.extend(edits); + self + } + /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) @@ -603,6 +611,24 @@ model_reasoning_effort = "high" assert_eq!(contents, expected); } + #[test] + fn builder_with_edits_applies_custom_paths() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits(vec![ConfigEdit::SetPath { + segments: vec!["enabled".to_string()], + value: value(true), + }]) + .apply_blocking() + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "enabled = true\n"); + } + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); From cb9a189857c881b04520f8636492795a473eaf7d Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 10 Dec 2025 11:19:00 -0800 Subject: [PATCH 75/94] make `model` optional in config (#7769) - Make Config.model optional and centralize default-selection logic in ModelsManager, including a default_model helper (with codex-auto-balanced when available) so sessions now carry an explicit chosen model separate from the base config. - Resolve `model` once in `core` and `tui` from config. Then store the state of it on other structs. - Move refreshing models to be before resolving the default model --- .../app-server/src/codex_message_processor.rs | 4 +- codex-rs/app-server/src/models.rs | 8 +- codex-rs/app-server/tests/common/lib.rs | 3 + .../app-server/tests/common/models_cache.rs | 74 ++++++++++ .../app-server/tests/suite/v2/model_list.rs | 4 + codex-rs/common/src/config_summary.rs | 4 +- codex-rs/core/src/client.rs | 8 +- codex-rs/core/src/codex.rs | 62 ++++----- codex-rs/core/src/config/mod.rs | 21 +-- codex-rs/core/src/conversation_manager.rs | 19 ++- codex-rs/core/src/model_provider_info.rs | 80 +++++------ .../core/src/openai_models/model_family.rs | 4 + .../core/src/openai_models/models_manager.rs | 106 +++++++++++++-- codex-rs/core/src/tasks/review.rs | 2 + .../core/tests/chat_completions_payload.rs | 21 ++- codex-rs/core/tests/chat_completions_sse.rs | 5 +- codex-rs/core/tests/common/responses.rs | 31 +++++ codex-rs/core/tests/common/test_codex.rs | 14 +- codex-rs/core/tests/responses_headers.rs | 19 ++- codex-rs/core/tests/suite/client.rs | 126 +++++++++++------- codex-rs/core/tests/suite/compact.rs | 82 ++++++++---- .../core/tests/suite/compact_resume_fork.rs | 24 ++-- .../core/tests/suite/fork_conversation.rs | 5 +- codex-rs/core/tests/suite/list_models.rs | 22 ++- codex-rs/core/tests/suite/model_overrides.rs | 14 +- codex-rs/core/tests/suite/prompt_caching.rs | 29 +++- codex-rs/core/tests/suite/remote_models.rs | 91 ++++++++++--- codex-rs/core/tests/suite/resume_warning.rs | 14 +- codex-rs/core/tests/suite/review.rs | 27 ++-- codex-rs/core/tests/suite/rmcp_client.rs | 8 +- codex-rs/core/tests/suite/unified_exec.rs | 51 ++----- codex-rs/core/tests/suite/user_shell_cmd.rs | 12 +- .../src/event_processor_with_human_output.rs | 3 +- codex-rs/exec/src/lib.rs | 5 +- codex-rs/lmstudio/src/lib.rs | 5 +- codex-rs/ollama/src/lib.rs | 5 +- codex-rs/tui/src/app.rs | 52 +++++--- codex-rs/tui/src/app_backtrack.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 23 ++-- codex-rs/tui/src/chatwidget/tests.rs | 52 ++++---- codex-rs/tui/src/history_cell.rs | 35 ++--- codex-rs/tui/src/status/card.rs | 7 +- codex-rs/tui/src/status/helpers.rs | 4 +- codex-rs/tui/src/status/tests.rs | 78 +++++++---- 44 files changed, 838 insertions(+), 429 deletions(-) create mode 100644 codex-rs/app-server/tests/common/models_cache.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8576c5c381c..7876cccf89c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1885,7 +1885,7 @@ impl CodexMessageProcessor { async fn list_models(&self, request_id: RequestId, params: ModelListParams) { let ModelListParams { limit, cursor } = params; - let models = supported_models(self.conversation_manager.clone()).await; + let models = supported_models(self.conversation_manager.clone(), &self.config).await; let total = models.len(); if total == 0 { @@ -2796,7 +2796,7 @@ impl CodexMessageProcessor { })?; let mut config = self.config.as_ref().clone(); - config.model = self.config.review_model.clone(); + config.model = Some(self.config.review_model.clone()); let NewConversation { conversation_id, diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 3ac71e85b90..21411603547 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -3,12 +3,16 @@ use std::sync::Arc; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; use codex_core::ConversationManager; +use codex_core::config::Config; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; -pub async fn supported_models(conversation_manager: Arc) -> Vec { +pub async fn supported_models( + conversation_manager: Arc, + config: &Config, +) -> Vec { conversation_manager - .list_models() + .list_models(config) .await .into_iter() .map(model_from_preset) diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 6fd54a66dc4..825b063c988 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -1,6 +1,7 @@ mod auth_fixtures; mod mcp_process; mod mock_model_server; +mod models_cache; mod responses; mod rollout; @@ -14,6 +15,8 @@ pub use core_test_support::format_with_current_shell_display; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; pub use mock_model_server::create_mock_chat_completions_server_unchecked; +pub use models_cache::write_models_cache; +pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs new file mode 100644 index 00000000000..8306e343941 --- /dev/null +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -0,0 +1,74 @@ +use chrono::DateTime; +use chrono::Utc; +use codex_core::openai_models::model_presets::all_model_presets; +use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use serde_json::json; +use std::path::Path; + +/// Convert a ModelPreset to ModelInfo for cache storage. +fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { + ModelInfo { + slug: preset.id.clone(), + display_name: preset.display_name.clone(), + description: Some(preset.description.clone()), + default_reasoning_level: preset.default_reasoning_effort, + supported_reasoning_levels: preset.supported_reasoning_efforts.clone(), + shell_type: ConfigShellToolType::ShellCommand, + visibility: if preset.show_in_picker { + ModelVisibility::List + } else { + ModelVisibility::Hide + }, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority, + upgrade: preset.upgrade.as_ref().map(|u| u.id.clone()), + base_instructions: None, + } +} + +/// Write a models_cache.json file to the codex home directory. +/// This prevents ModelsManager from making network requests to refresh models. +/// The cache will be treated as fresh (within TTL) and used instead of fetching from the network. +/// Uses the built-in model presets from ModelsManager, converted to ModelInfo format. +pub fn write_models_cache(codex_home: &Path) -> std::io::Result<()> { + // Get all presets and filter for show_in_picker (same as builtin_model_presets does) + let presets: Vec<&ModelPreset> = all_model_presets() + .iter() + .filter(|preset| preset.show_in_picker) + .collect(); + // Convert presets to ModelInfo, assigning priorities (higher = earlier in list) + // Priority is used for sorting, so first model gets highest priority + let models: Vec = presets + .iter() + .enumerate() + .map(|(idx, preset)| { + // Higher priority = earlier in list, so reverse the index + let priority = (presets.len() - idx) as i32; + preset_to_info(preset, priority) + }) + .collect(); + + write_models_cache_with_models(codex_home, models) +} + +/// Write a models_cache.json file with specific models. +/// Useful when tests need specific models to be available. +pub fn write_models_cache_with_models( + codex_home: &Path, + models: Vec, +) -> std::io::Result<()> { + let cache_path = codex_home.join("models_cache.json"); + // DateTime serializes to RFC3339 format by default with serde + let fetched_at: DateTime = Utc::now(); + let cache = json!({ + "fetched_at": fetched_at, + "etag": null, + "models": models + }); + std::fs::write(cache_path, serde_json::to_string_pretty(&cache)?) +} diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 8ca85c9c3b9..0e0f607e268 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -4,6 +4,7 @@ use anyhow::Result; use anyhow::anyhow; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_models_cache; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::Model; @@ -22,6 +23,7 @@ const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -151,6 +153,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { #[tokio::test] async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -248,6 +251,7 @@ async fn list_models_pagination_works() -> Result<()> { #[tokio::test] async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/common/src/config_summary.rs b/codex-rs/common/src/config_summary.rs index 32b837f1f52..5a5901880f2 100644 --- a/codex-rs/common/src/config_summary.rs +++ b/codex-rs/common/src/config_summary.rs @@ -4,10 +4,10 @@ use codex_core::config::Config; use crate::sandbox_summary::summarize_sandbox_policy; /// Build a list of key/value pairs summarizing the effective configuration. -pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> { +pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'static str, String)> { let mut entries = vec![ ("workdir", config.cwd.display().to_string()), - ("model", config.model.clone()), + ("model", model.to_string()), ("provider", config.model_provider_id.clone()), ("approval", config.approval_policy.to_string()), ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d4a714cdd52..72c23a3ea40 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -166,7 +166,7 @@ impl ModelClient { let stream_result = client .stream_prompt( - &self.config.model, + &self.get_model(), &api_prompt, Some(conversation_id.clone()), Some(session_source.clone()), @@ -260,7 +260,7 @@ impl ModelClient { }; let stream_result = client - .stream_prompt(&self.config.model, &api_prompt, options) + .stream_prompt(&self.get_model(), &api_prompt, options) .await; match stream_result { @@ -292,7 +292,7 @@ impl ModelClient { /// Returns the currently configured model slug. pub fn get_model(&self) -> String { - self.config.model.clone() + self.get_model_family().get_model_slug().to_string() } /// Returns the currently configured model family. @@ -337,7 +337,7 @@ impl ModelClient { .get_full_instructions(&self.get_model_family()) .into_owned(); let payload = ApiCompactionInput { - model: &self.config.model, + model: &self.get_model(), input: &prompt.input, instructions: &instructions, }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6f637d143c7..22570ad1b35 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -181,10 +181,15 @@ impl Codex { let exec_policy = Arc::new(RwLock::new(exec_policy)); let config = Arc::new(config); - + if config.features.enabled(Feature::RemoteModels) + && let Err(err) = models_manager.refresh_available_models(&config).await + { + error!("failed to refresh available models: {err:?}"); + } + let model = models_manager.get_model(&config.model, &config).await; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model: model.clone(), model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -398,10 +403,11 @@ pub(crate) struct SessionSettingsUpdate { } impl Session { + /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { + // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); - per_turn_config.model = session_configuration.model.clone(); per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; per_turn_config.features = config.features.clone(); @@ -421,7 +427,7 @@ impl Session { ) -> TurnContext { let otel_event_manager = otel_event_manager.clone().with_model( session_configuration.model.as_str(), - model_family.slug.as_str(), + model_family.get_model_slug(), ); let per_turn_config = Arc::new(per_turn_config); @@ -544,14 +550,11 @@ impl Session { }); } - let model_family = models_manager - .construct_model_family(&config.model, &config) - .await; // todo(aibrahim): why are we passing model here while it can change? let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), - model_family.slug.as_str(), + session_configuration.model.as_str(), + session_configuration.model.as_str(), auth_manager.auth().and_then(|a| a.get_account_id()), auth_manager.auth().and_then(|a| a.get_account_email()), auth_manager.auth().map(|a| a.mode), @@ -780,7 +783,7 @@ impl Session { let model_family = self .services .models_manager - .construct_model_family(&per_turn_config.model, &per_turn_config) + .construct_model_family(session_configuration.model.as_str(), &per_turn_config) .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), @@ -1444,16 +1447,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv let mut previous_context: Option> = Some(sess.new_turn(SessionSettingsUpdate::default()).await); - if config.features.enabled(Feature::RemoteModels) - && let Err(err) = sess - .services - .models_manager - .refresh_available_models(&config.model_provider) - .await - { - error!("failed to refresh available models: {err}"); - } - // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); @@ -1925,7 +1918,6 @@ async fn spawn_review_thread( // Build per‑turn client with the requested model/family. let mut per_turn_config = (*config).clone(); - per_turn_config.model = model.clone(); per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; per_turn_config.features = review_features.clone(); @@ -1934,7 +1926,7 @@ async fn spawn_review_thread( .client .get_otel_event_manager() .with_model( - per_turn_config.model.as_str(), + config.review_model.as_str(), review_model_family.slug.as_str(), ); @@ -2555,9 +2547,10 @@ mod tests { ) .expect("load default test config"); let config = Arc::new(config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2626,9 +2619,10 @@ mod tests { ) .expect("load default test config"); let config = Arc::new(config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2803,7 +2797,7 @@ mod tests { ) -> OtelEventManager { OtelEventManager::new( conversation_id, - config.model.as_str(), + ModelsManager::get_model_offline(config.model.as_deref()).as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -2827,9 +2821,10 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2844,8 +2839,10 @@ mod tests { session_source: SessionSource::Exec, }; let per_turn_config = Session::build_per_turn_config(&session_configuration); - let model_family = - ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let model_family = ModelsManager::construct_model_family_offline( + session_configuration.model.as_str(), + &per_turn_config, + ); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref(), &model_family); @@ -2909,9 +2906,10 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2926,8 +2924,10 @@ mod tests { session_source: SessionSource::Exec, }; let per_turn_config = Session::build_per_turn_config(&session_configuration); - let model_family = - ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let model_family = ModelsManager::construct_model_family_offline( + session_configuration.model.as_str(), + &per_turn_config, + ); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref(), &model_family); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index bdf7a541772..e0e6985a39d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -59,7 +59,6 @@ pub mod edit; pub mod profile; pub mod types; -pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max"; /// Maximum number of bytes of the documentation that will be embedded. Larger @@ -73,7 +72,7 @@ pub const CONFIG_TOML_FILE: &str = "config.toml"; #[derive(Debug, Clone, PartialEq)] pub struct Config { /// Optional override of model selection. - pub model: String, + pub model: Option, /// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max". pub review_model: String, @@ -1108,11 +1107,7 @@ impl Config { let forced_login_method = cfg.forced_login_method; - // todo(aibrahim): make model optional - let model = model - .or(config_profile.model) - .or(cfg.model) - .unwrap_or_else(default_model); + let model = model.or(config_profile.model).or(cfg.model); let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); @@ -1313,10 +1308,6 @@ impl Config { } } -fn default_model() -> String { - OPENAI_DEFAULT_MODEL.to_string() -} - fn default_review_model() -> String { OPENAI_DEFAULT_REVIEW_MODEL.to_string() } @@ -2940,7 +2931,7 @@ model_verbosity = "high" )?; assert_eq!( Config { - model: "o3".to_string(), + model: Some("o3".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, @@ -3014,7 +3005,7 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_gpt3_profile_config = Config { - model: "gpt-3.5-turbo".to_string(), + model: Some("gpt-3.5-turbo".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, @@ -3103,7 +3094,7 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_zdr_profile_config = Config { - model: "o3".to_string(), + model: Some("o3".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, @@ -3178,7 +3169,7 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_gpt5_profile_config = Config { - model: "gpt-5.1".to_string(), + model: Some("gpt-5.1".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index b1818849eb4..f340e1a8333 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -1,5 +1,7 @@ use crate::AuthManager; use crate::CodexAuth; +#[cfg(any(test, feature = "test-support"))] +use crate::ModelProviderInfo; use crate::codex::Codex; use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; @@ -54,11 +56,14 @@ impl ConversationManager { #[cfg(any(test, feature = "test-support"))] /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. - pub fn with_auth(auth: CodexAuth) -> Self { - Self::new( - crate::AuthManager::from_auth_for_testing(auth), - SessionSource::Exec, - ) + pub fn with_models_provider(auth: CodexAuth, provider: ModelProviderInfo) -> Self { + let auth_manager = crate::AuthManager::from_auth_for_testing(auth); + Self { + conversations: Arc::new(RwLock::new(HashMap::new())), + auth_manager: auth_manager.clone(), + session_source: SessionSource::Exec, + models_manager: Arc::new(ModelsManager::with_provider(auth_manager, provider)), + } } pub fn session_source(&self) -> SessionSource { @@ -213,8 +218,8 @@ impl ConversationManager { self.finalize_spawn(codex, conversation_id).await } - pub async fn list_models(&self) -> Vec { - self.models_manager.list_models().await + pub async fn list_models(&self, config: &Config) -> Vec { + self.models_manager.list_models(config).await } pub fn get_models_manager(&self) -> Arc { diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 4912a64694b..82072fc2aa5 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -208,6 +208,45 @@ impl ModelProviderInfo { .map(Duration::from_millis) .unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS)) } + pub fn create_openai_provider() -> ModelProviderInfo { + ModelProviderInfo { + name: "OpenAI".into(), + // Allow users to override the default OpenAI endpoint by + // exporting `OPENAI_BASE_URL`. This is useful when pointing + // Codex at a proxy, mock server, or Azure-style deployment + // without requiring a full TOML override for the built-in + // OpenAI provider. + base_url: std::env::var("OPENAI_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some( + [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] + .into_iter() + .collect(), + ), + env_http_headers: Some( + [ + ( + "OpenAI-Organization".to_string(), + "OPENAI_ORGANIZATION".to_string(), + ), + ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), + ] + .into_iter() + .collect(), + ), + // Use global defaults for retry/timeout unless overridden in config.toml. + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: true, + } + } } pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234; @@ -225,46 +264,7 @@ pub fn built_in_model_providers() -> HashMap { // open source ("oss") providers by default. Users are encouraged to add to // `model_providers` in config.toml to add their own providers. [ - ( - "openai", - P { - name: "OpenAI".into(), - // Allow users to override the default OpenAI endpoint by - // exporting `OPENAI_BASE_URL`. This is useful when pointing - // Codex at a proxy, mock server, or Azure-style deployment - // without requiring a full TOML override for the built-in - // OpenAI provider. - base_url: std::env::var("OPENAI_BASE_URL") - .ok() - .filter(|v| !v.trim().is_empty()), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: Some( - [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] - .into_iter() - .collect(), - ), - env_http_headers: Some( - [ - ( - "OpenAI-Organization".to_string(), - "OPENAI_ORGANIZATION".to_string(), - ), - ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), - ] - .into_iter() - .collect(), - ), - // Use global defaults for retry/timeout unless overridden in config.toml. - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - requires_openai_auth: true, - }, - ), + ("openai", P::create_openai_provider()), ( OLLAMA_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat), diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 8a3853d60bf..2cc6fd08442 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -116,6 +116,10 @@ impl ModelFamily { const fn default_auto_compact_limit(context_window: i64) -> i64 { (context_window * 9) / 10 } + + pub fn get_model_slug(&self) -> &str { + &self.slug + } } macro_rules! model_family { diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 03dbd39d3d4..de9aa0f7c87 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -21,6 +21,7 @@ use crate::auth::AuthManager; use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::Result as CoreResult; +use crate::features::Feature; use crate::model_provider_info::ModelProviderInfo; use crate::openai_models::model_family::ModelFamily; use crate::openai_models::model_family::find_family_for_model; @@ -28,6 +29,8 @@ use crate::openai_models::model_presets::builtin_model_presets; const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); +const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; +const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; /// Coordinates remote model discovery plus cached metadata on disk. #[derive(Debug)] @@ -39,6 +42,7 @@ pub struct ModelsManager { etag: RwLock>, codex_home: PathBuf, cache_ttl: Duration, + provider: ModelProviderInfo, } impl ModelsManager { @@ -52,18 +56,37 @@ impl ModelsManager { etag: RwLock::new(None), codex_home, cache_ttl: DEFAULT_MODEL_CACHE_TTL, + provider: ModelProviderInfo::create_openai_provider(), + } + } + + #[cfg(any(test, feature = "test-support"))] + /// Construct a manager scoped to the provided `AuthManager` with a specific provider. Used for integration tests. + pub fn with_provider(auth_manager: Arc, provider: ModelProviderInfo) -> Self { + let codex_home = auth_manager.codex_home().to_path_buf(); + Self { + available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), + remote_models: RwLock::new(Vec::new()), + auth_manager, + etag: RwLock::new(None), + codex_home, + cache_ttl: DEFAULT_MODEL_CACHE_TTL, + provider, } } /// Fetch the latest remote models, using the on-disk cache when still fresh. - pub async fn refresh_available_models(&self, provider: &ModelProviderInfo) -> CoreResult<()> { + pub async fn refresh_available_models(&self, config: &Config) -> CoreResult<()> { + if !config.features.enabled(Feature::RemoteModels) { + return Ok(()); + } if self.try_load_cache().await { return Ok(()); } let auth = self.auth_manager.auth(); - let api_provider = provider.to_api_provider(Some(AuthMode::ChatGPT))?; - let api_auth = auth_provider_from_auth(auth.clone(), provider).await?; + let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; + let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?; let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); @@ -81,7 +104,10 @@ impl ModelsManager { Ok(()) } - pub async fn list_models(&self) -> Vec { + pub async fn list_models(&self, config: &Config) -> Vec { + if let Err(err) = self.refresh_available_models(config).await { + error!("failed to refresh available models: {err}"); + } self.available_models.read().await.clone() } @@ -98,6 +124,33 @@ impl ModelsManager { .with_remote_overrides(self.remote_models.read().await.clone()) } + pub async fn get_model(&self, model: &Option, config: &Config) -> String { + if let Some(model) = model.as_ref() { + return model.to_string(); + } + if let Err(err) = self.refresh_available_models(config).await { + error!("failed to refresh available models: {err}"); + } + // if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model + let auth_mode = self.auth_manager.get_auth_mode(); + if auth_mode == Some(AuthMode::ChatGPT) + && self + .available_models + .read() + .await + .iter() + .any(|m| m.model == CODEX_AUTO_BALANCED_MODEL) + { + return CODEX_AUTO_BALANCED_MODEL.to_string(); + } + OPENAI_DEFAULT_MODEL.to_string() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_model_offline(model: Option<&str>) -> String { + model.unwrap_or(OPENAI_DEFAULT_MODEL).to_string() + } + #[cfg(any(test, feature = "test-support"))] /// Offline helper that builds a `ModelFamily` without consulting remote state. pub fn construct_model_family_offline(model: &str, config: &Config) -> ModelFamily { @@ -112,6 +165,7 @@ impl ModelsManager { /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. async fn try_load_cache(&self) -> bool { + // todo(aibrahim): think if we should store fetched_at in ModelsManager so we don't always need to read the disk let cache_path = self.cache_path(); let cache = match cache::load_cache(&cache_path).await { Ok(cache) => cache, @@ -197,6 +251,10 @@ mod tests { use super::*; use crate::CodexAuth; use crate::auth::AuthCredentialsStoreMode; + use crate::config::Config; + use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use crate::features::Feature; use crate::model_provider_info::WireApi; use codex_protocol::openai_models::ModelsResponse; use core_test_support::responses::mount_models_once; @@ -256,19 +314,27 @@ mod tests { ) .await; + let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("refresh succeeds"); let cached_remote = manager.remote_models.read().await.clone(); assert_eq!(cached_remote, remote_models); - let available = manager.list_models().await; + let available = manager.list_models(&config).await; assert_eq!(available.len(), 2); assert_eq!(available[0].model, "priority-high"); assert!( @@ -298,16 +364,23 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), false, AuthCredentialsStoreMode::File, )); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("first refresh succeeds"); assert_eq!( @@ -318,7 +391,7 @@ mod tests { // Second call should read from cache and avoid the network. manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("cached refresh succeeds"); assert_eq!( @@ -347,16 +420,23 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), false, AuthCredentialsStoreMode::File, )); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("initial refresh succeeds"); @@ -382,7 +462,7 @@ mod tests { .await; manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("second refresh succeeds"); assert_eq!( diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 5c2e8d08b9a..da7f29d4ad6 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -92,6 +92,8 @@ async fn start_review_conversation( // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); + + sub_agent_config.model = Some(config.review_model.clone()); (run_codex_conversation_one_shot( sub_agent_config, session.auth_manager(), diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index 1449a833dae..6bfad437833 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -1,3 +1,5 @@ +#![allow(clippy::expect_used)] + use std::sync::Arc; use codex_app_server_protocol::AuthMode; @@ -71,10 +73,11 @@ async fn run_request(input: Vec) -> Value { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -108,11 +111,15 @@ async fn run_request(input: Vec) -> Value { } } - let requests = match server.received_requests().await { - Some(reqs) => reqs, - None => panic!("request not made"), - }; - match requests[0].body_json() { + let all_requests = server.received_requests().await.expect("received requests"); + let requests: Vec<_> = all_requests + .iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) + .collect(); + let request = requests + .first() + .unwrap_or_else(|| panic!("expected POST request to /chat/completions")); + match request.body_json() { Ok(v) => v, Err(e) => panic!("invalid json body: {e}"), } diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index fe7ec58945a..9124d59d13c 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -74,10 +74,11 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let auth_mode = auth_manager.get_auth_mode(); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index c67daeda875..b98b29625eb 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -689,6 +689,33 @@ pub async fn start_mock_server() -> MockServer { server } +// todo(aibrahim): remove this and use our search matching patterns directly +/// Get all POST requests to `/responses` endpoints from the mock server. +/// Filters out GET requests (e.g., `/models`) . +pub async fn get_responses_requests(server: &MockServer) -> Vec { + server + .received_requests() + .await + .expect("mock server should not fail") + .into_iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/responses")) + .collect() +} + +// todo(aibrahim): remove this and use our search matching patterns directly +/// Get request bodies as JSON values from POST requests to `/responses` endpoints. +/// Filters out GET requests (e.g., `/models`) . +pub async fn get_responses_request_bodies(server: &MockServer) -> Vec { + get_responses_requests(server) + .await + .into_iter() + .map(|req| { + req.body_json::() + .expect("request body to be valid JSON") + }) + .collect() +} + #[derive(Clone)] pub struct FunctionCallResponseMocks { pub function_call: ResponseMock, @@ -769,6 +796,10 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) -> Res /// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call` /// in the `input` must have a matching output entry. fn validate_request_body_invariants(request: &wiremock::Request) { + // Skip GET requests (e.g., /models) + if request.method != "POST" || !request.url.path().ends_with("/responses") { + return; + } let Ok(body): Result = request.body_json() else { return; }; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 23bcadadf15..5f38dbd4b50 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -23,6 +23,7 @@ use tempfile::TempDir; use wiremock::MockServer; use crate::load_default_config_for_test; +use crate::responses::get_responses_request_bodies; use crate::responses::start_mock_server; use crate::wait_for_event; @@ -69,7 +70,7 @@ impl TestCodexBuilder { pub fn with_model(self, model: &str) -> Self { let new_model = model.to_string(); self.with_config(move |config| { - config.model = new_model.clone(); + config.model = Some(new_model.clone()); }) } @@ -96,7 +97,8 @@ impl TestCodexBuilder { let (config, cwd) = self.prepare_config(server, &home).await?; let auth = self.auth.clone(); - let conversation_manager = ConversationManager::with_auth(auth.clone()); + let conversation_manager = + ConversationManager::with_models_provider(auth.clone(), config.model_provider.clone()); let new_conversation = match resume_from { Some(path) => { @@ -272,13 +274,7 @@ impl TestCodexHarness { } pub async fn request_bodies(&self) -> Vec { - self.server - .received_requests() - .await - .expect("requests") - .into_iter() - .map(|req| serde_json::from_slice(&req.body).expect("request body json")) - .collect() + get_responses_request_bodies(&self.server).await } pub async fn function_call_output_value(&self, call_id: &str) -> Value { diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index d79de721671..934c327a6c9 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -61,14 +61,16 @@ async fn responses_stream_includes_subagent_header_on_review() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -151,15 +153,17 @@ async fn responses_stream_includes_subagent_header_on_other() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -235,7 +239,7 @@ async fn responses_respects_model_family_overrides_from_config() { let codex_home = TempDir::new().expect("failed to create TempDir"); let mut config = load_default_config_for_test(&codex_home); - config.model = "gpt-3.5-turbo".to_string(); + config.model = Some("gpt-3.5-turbo".to_string()); config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); config.model_supports_reasoning_summaries = Some(true); @@ -243,15 +247,16 @@ async fn responses_respects_model_family_overrides_from_config() { config.model_reasoning_summary = ReasoningSummary::Detailed; let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = config.model.clone().expect("model configured"); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")).get_auth_mode(); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8b3d63a4140..faa9801f86f 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -30,7 +30,12 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; 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::responses::ev_completed_with_tokens; +use core_test_support::responses::get_responses_requests; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_once_match; +use core_test_support::responses::sse; +use core_test_support::responses::sse_failed; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; @@ -240,7 +245,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Mock server that will receive the resumed request let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; // Configure Codex to resume from our file let model_provider = ModelProviderInfo { @@ -253,8 +258,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Also configure user instructions to ensure they are NOT delivered on resume. config.user_instructions = Some("be nice".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let NewConversation { @@ -337,8 +344,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, conversation_id, @@ -360,7 +369,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let request_conversation_id = request.headers.get("conversation_id").unwrap(); let request_authorization = request.headers.get("authorization").unwrap(); let request_originator = request.headers.get("originator").unwrap(); @@ -381,7 +393,7 @@ async fn includes_base_instructions_override_in_request() { skip_if_no_network!(); // Mock server let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -393,8 +405,10 @@ async fn includes_base_instructions_override_in_request() { config.base_instructions = Some("test instructions".to_string()); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -451,7 +465,10 @@ async fn chatgpt_auth_sends_correct_request() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, conversation_id, @@ -473,7 +490,10 @@ async fn chatgpt_auth_sends_correct_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let request_conversation_id = request.headers.get("conversation_id").unwrap(); let request_authorization = request.headers.get("authorization").unwrap(); let request_originator = request.headers.get("originator").unwrap(); @@ -569,7 +589,7 @@ async fn includes_user_instructions_message_in_request() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -581,8 +601,10 @@ async fn includes_user_instructions_message_in_request() { config.model_provider = model_provider; config.user_instructions = Some("be nice".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -627,7 +649,7 @@ async fn skills_append_to_instructions_when_feature_enabled() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -648,8 +670,10 @@ async fn skills_append_to_instructions_when_feature_enabled() { config.features.enable(Feature::Skills); config.cwd = codex_home.path().to_path_buf(); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -695,7 +719,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .with_config(|config| { @@ -734,7 +758,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .build(&server) @@ -771,7 +795,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_fami skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex().with_model("gpt-5.1").build(&server).await?; codex @@ -804,7 +828,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex().with_model("gpt-5.1").build(&server).await?; codex @@ -837,7 +861,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .with_config(|config| { @@ -875,7 +899,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1") .with_config(|config| { @@ -914,7 +938,7 @@ async fn includes_developer_instructions_message_in_request() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -927,8 +951,10 @@ async fn includes_developer_instructions_message_in_request() { config.user_instructions = Some("be nice".to_string()); config.developer_instructions = Some("be useful".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1014,13 +1040,15 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -1103,11 +1131,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { } } - let requests = server - .received_requests() - .await - .expect("mock server collected requests"); - assert_eq!(requests.len(), 1, "expected a single request"); + let requests = get_responses_requests(&server).await; + assert_eq!(requests.len(), 1, "expected a single POST request"); let body: serde_json::Value = requests[0] .body_json() .expect("request body to be valid JSON"); @@ -1128,7 +1153,7 @@ async fn token_count_includes_rate_limits_snapshot() { skip_if_no_network!(); let server = MockServer::start().await; - let sse_body = responses::sse(vec![responses::ev_completed_with_tokens("resp_rate", 123)]); + let sse_body = sse(vec![ev_completed_with_tokens("resp_rate", 123)]); let response = ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") @@ -1154,7 +1179,10 @@ async fn token_count_includes_rate_limits_snapshot() { 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 conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1361,10 +1389,10 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res const EFFECTIVE_CONTEXT_WINDOW: i64 = (272_000 * 95) / 100; - responses::mount_sse_once_match( + mount_sse_once_match( &server, body_string_contains("trigger context window"), - responses::sse_failed( + sse_failed( "resp_context_window", "context_length_exceeded", "Your input exceeds the context window of this model. Please adjust your input and try again.", @@ -1372,7 +1400,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res ) .await; - responses::mount_sse_once_match( + mount_sse_once_match( &server, body_string_contains("seed turn"), sse_completed("resp_seed"), @@ -1381,7 +1409,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.model = "gpt-5.1".to_string(); + config.model = Some("gpt-5.1".to_string()); config.model_context_window = Some(272_000); }) .build(&server) @@ -1505,7 +1533,10 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1583,7 +1614,10 @@ async fn env_var_overrides_loaded_auth() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1661,8 +1695,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -1699,7 +1735,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Inspect the three captured requests. - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!(requests.len(), 3, "expected 3 requests (one per turn)"); // Replace full-array compare with tail-only raw JSON compare using a single hard-coded value. diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index aa74ec89782..521a76845ab 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -28,6 +28,7 @@ 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::get_responses_requests; use core_test_support::responses::mount_compact_json_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; @@ -135,7 +136,10 @@ async fn summarize_context_three_requests_and_instructions() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, session_configured, @@ -329,7 +333,10 @@ async fn manual_compact_uses_custom_prompt() { config.model_provider = model_provider; config.compact_prompt = Some(custom_prompt.to_string()); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -344,7 +351,7 @@ async fn manual_compact_uses_custom_prompt() { assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.expect("collect requests"); + let requests = get_responses_requests(&server).await; let body = requests .iter() .find_map(|req| req.body_json::().ok()) @@ -409,7 +416,10 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -570,7 +580,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // collect the requests payloads from the model - let requests_payloads = server.received_requests().await.unwrap(); + let requests_payloads = get_responses_requests(&server).await; let body = requests_payloads[0] .body_json::() @@ -1050,7 +1060,10 @@ async fn auto_compact_runs_after_token_limit_hit() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1090,7 +1103,7 @@ async fn auto_compact_runs_after_token_limit_hit() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!( requests.len(), 5, @@ -1295,7 +1308,10 @@ async fn auto_compact_persists_rollout_entries() { let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, session_configured, @@ -1397,11 +1413,14 @@ async fn manual_compact_retries_after_context_window_error() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { @@ -1529,11 +1548,14 @@ async fn manual_compact_twice_preserves_latest_user_messages() { let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { @@ -1731,7 +1753,10 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1771,10 +1796,8 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "auto compact should not emit task lifecycle events" ); - let request_bodies: Vec = server - .received_requests() - .await - .unwrap() + let requests = get_responses_requests(&server).await; + let request_bodies: Vec = requests .into_iter() .map(|request| String::from_utf8(request.body).unwrap_or_default()) .collect(); @@ -1845,11 +1868,14 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { config.model_context_window = Some(context_window); config.model_auto_compact_token_limit = Some(limit); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index f81294baf30..5d3d9e4b8a7 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -26,6 +26,7 @@ use codex_protocol::user_input::UserInput; 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::get_responses_request_bodies; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; use core_test_support::wait_for_event; @@ -771,17 +772,11 @@ fn normalize_line_endings(value: &mut Value) { } async fn gather_request_bodies(server: &MockServer) -> Vec { - server - .received_requests() - .await - .expect("mock server should not fail") - .into_iter() - .map(|req| { - let mut value = req.body_json::().expect("valid JSON body"); - normalize_line_endings(&mut value); - value - }) - .collect() + let mut bodies = get_responses_request_bodies(server).await; + for body in &mut bodies { + normalize_line_endings(body); + } + bodies } async fn mount_initial_flow(server: &MockServer) { @@ -870,9 +865,12 @@ async fn start_test_conversation( config.model_provider = model_provider; config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); if let Some(model) = model { - config.model = model.to_string(); + config.model = Some(model.to_string()); } - let manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation, .. } = manager .new_conversation(config.clone()) .await diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs index 75b37ae7ef2..a82b4762147 100644 --- a/codex-rs/core/tests/suite/fork_conversation.rs +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -55,7 +55,10 @@ async fn fork_conversation_twice_drops_to_first_message() { config.model_provider = model_provider.clone(); let config_for_fork = config.clone(); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index 6348841c6fb..70df5174f3e 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -1,15 +1,23 @@ use anyhow::Result; use codex_core::CodexAuth; use codex_core::ConversationManager; +use codex_core::built_in_model_providers; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; +use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; +use tempfile::tempdir; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_api_key_models() -> Result<()> { - let manager = ConversationManager::with_auth(CodexAuth::from_api_key("sk-test")); - let models = manager.list_models().await; + let codex_home = tempdir()?; + let config = load_default_config_for_test(&codex_home); + let manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("sk-test"), + built_in_model_providers()["openai"].clone(), + ); + let models = manager.list_models(&config).await; let expected_models = expected_models_for_api_key(); assert_eq!(expected_models, models); @@ -19,9 +27,13 @@ async fn list_models_returns_api_key_models() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_chatgpt_models() -> Result<()> { - let manager = - ConversationManager::with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let models = manager.list_models().await; + let codex_home = tempdir()?; + let config = load_default_config_for_test(&codex_home); + let manager = ConversationManager::with_models_provider( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + built_in_model_providers()["openai"].clone(), + ); + let models = manager.list_models(&config).await; let expected_models = expected_models_for_chatgpt(); assert_eq!(expected_models, models); diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index f67196312fc..53a45e67868 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -20,10 +20,12 @@ async fn override_turn_context_does_not_persist_when_config_exists() { .expect("seed config.toml"); let mut config = load_default_config_for_test(&codex_home); - config.model = "gpt-4o".to_string(); + config.model = Some("gpt-4o".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -62,8 +64,10 @@ async fn override_turn_context_does_not_create_config_file() { let config = load_default_config_for_test(&codex_home); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 2bc71298d43..94158df6d85 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -71,7 +71,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); config.features.disable(Feature::ApplyPatchFreeform); - config.model = "codex-mini-latest".to_string(); + config.model = Some("codex-mini-latest".to_string()); }) .build(&server) .await?; @@ -131,12 +131,19 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.model = Some("gpt-5.1-codex-max".to_string()); }) .build(&server) .await?; let base_instructions = conversation_manager .get_models_manager() - .construct_model_family(&config.model, &config) + .construct_model_family( + config + .model + .as_deref() + .expect("test config should have a model"), + &config, + ) .await .base_instructions .clone(); @@ -572,7 +579,12 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; - let TestCodex { codex, config, .. } = test_codex() + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); }) @@ -582,7 +594,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); + let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -659,7 +671,12 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; - let TestCodex { codex, config, .. } = test_codex() + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); }) @@ -669,7 +686,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); + let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 0f80407473e..707ab6fa45a 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -3,6 +3,12 @@ use std::sync::Arc; use anyhow::Result; +use codex_core::CodexAuth; +use codex_core::CodexConversation; +use codex_core::ConversationManager; +use codex_core::ModelProviderInfo; +use codex_core::built_in_model_providers; +use codex_core::config::Config; use codex_core::features::Feature; use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::AskForApproval; @@ -20,6 +26,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::user_input::UserInput; +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::ev_function_call; @@ -30,11 +37,10 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; -use core_test_support::test_codex::TestCodex; -use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use serde_json::json; +use tempfile::TempDir; use tokio::time::Duration; use tokio::time::Instant; use tokio::time::sleep; @@ -80,21 +86,23 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { ) .await; - let mut builder = test_codex().with_config(|config| { + let harness = build_remote_models_harness(&server, |config| { config.features.enable(Feature::RemoteModels); - config.model = "gpt-5.1".to_string(); - }); + config.model = Some("gpt-5.1".to_string()); + }) + .await?; - let TestCodex { + let RemoteModelsHarness { codex, cwd, config, conversation_manager, .. - } = builder.build(&server).await?; + } = harness; let models_manager = conversation_manager.get_models_manager(); - let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await; + let available_model = + wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG, &config).await; assert_eq!(available_model.model, REMOTE_MODEL_SLUG); @@ -218,20 +226,22 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { ) .await; - let mut builder = test_codex().with_config(|config| { + let harness = build_remote_models_harness(&server, |config| { config.features.enable(Feature::RemoteModels); - config.model = "gpt-5.1".to_string(); - }); + config.model = Some("gpt-5.1".to_string()); + }) + .await?; - let TestCodex { + let RemoteModelsHarness { codex, cwd, + config, conversation_manager, .. - } = builder.build(&server).await?; + } = harness; let models_manager = conversation_manager.get_models_manager(); - wait_for_model_available(&models_manager, model).await; + wait_for_model_available(&models_manager, model, &config).await; codex .submit(Op::OverrideTurnContext { @@ -268,11 +278,15 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { Ok(()) } -async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { +async fn wait_for_model_available( + manager: &Arc, + slug: &str, + config: &Config, +) -> ModelPreset { let deadline = Instant::now() + Duration::from_secs(2); loop { if let Some(model) = { - let guard = manager.list_models().await; + let guard = manager.list_models(config).await; guard.iter().find(|model| model.model == slug).cloned() } { return model; @@ -283,3 +297,48 @@ async fn wait_for_model_available(manager: &Arc, slug: &str) -> M sleep(Duration::from_millis(25)).await; } } + +struct RemoteModelsHarness { + codex: Arc, + cwd: Arc, + config: Config, + conversation_manager: Arc, +} + +// todo(aibrahim): move this to with_model_provier in test_codex +async fn build_remote_models_harness( + server: &MockServer, + mutate_config: F, +) -> Result +where + F: FnOnce(&mut Config), +{ + let auth = CodexAuth::from_api_key("dummy"); + let home = Arc::new(TempDir::new()?); + let cwd = Arc::new(TempDir::new()?); + + let mut config = load_default_config_for_test(&home); + config.cwd = cwd.path().to_path_buf(); + config.features.enable(Feature::RemoteModels); + + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + config.model_provider = provider.clone(); + + mutate_config(&mut config); + + let conversation_manager = Arc::new(ConversationManager::with_models_provider(auth, provider)); + + let new_conversation = conversation_manager + .new_conversation(config.clone()) + .await?; + + Ok(RemoteModelsHarness { + codex: new_conversation.conversation, + cwd, + config, + conversation_manager, + }) +} diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index c8376e41099..cb83ab06dc5 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -4,6 +4,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::NewConversation; +use codex_core::built_in_model_providers; use codex_core::protocol::EventMsg; use codex_core::protocol::InitialHistory; use codex_core::protocol::ResumedHistory; @@ -16,7 +17,11 @@ use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use tempfile::TempDir; -fn resume_history(config: &codex_core::config::Config, previous_model: &str, rollout_path: &std::path::Path) -> InitialHistory { +fn resume_history( + config: &codex_core::config::Config, + previous_model: &str, + rollout_path: &std::path::Path, +) -> InitialHistory { let turn_ctx = TurnContextItem { cwd: config.cwd.clone(), approval_policy: config.approval_policy, @@ -38,7 +43,7 @@ async fn emits_warning_when_resumed_model_differs() { // Arrange a config with a current model and a prior rollout recorded under a different model. let home = TempDir::new().expect("tempdir"); let mut config = load_default_config_for_test(&home); - config.model = "current-model".to_string(); + config.model = Some("current-model".to_string()); // Ensure cwd is absolute (the helper sets it to the temp dir already). assert!(config.cwd.is_absolute()); @@ -47,7 +52,10 @@ async fn emits_warning_when_resumed_model_differs() { let initial_history = resume_history(&config, "previous-model", &rollout_path); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); // Act: resume the conversation. diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index b3a52cfa540..ca8af6ad1e2 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -23,6 +23,7 @@ use codex_core::review_format::render_review_output_text; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id_from_str; +use core_test_support::responses::get_responses_requests; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -394,7 +395,7 @@ async fn review_uses_custom_review_model_from_config() { let codex_home = TempDir::new().unwrap(); // Choose a review model different from the main model; ensure it is used. let codex = new_conversation_for_server(&server, &codex_home, |cfg| { - cfg.model = "gpt-4.1".to_string(); + cfg.model = Some("gpt-4.1".to_string()); cfg.review_model = "gpt-5.1".to_string(); }) .await; @@ -425,7 +426,10 @@ async fn review_uses_custom_review_model_from_config() { let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Assert the request body model equals the configured review model - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let body = request.body_json::().unwrap(); assert_eq!(body["model"].as_str().unwrap(), "gpt-5.1"); @@ -543,7 +547,10 @@ async fn review_input_isolated_from_parent_history() { let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Assert the request `input` contains the environment context followed by the user review prompt. - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let body = request.body_json::().unwrap(); let input = body["input"].as_array().expect("input array"); assert_eq!( @@ -673,7 +680,7 @@ async fn review_history_surfaces_in_parent_session() { // Inspect the second request (parent turn) input contents. // Parent turns include session initial messages (user_instructions, environment_context). // Critically, no messages from the review thread should appear. - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!(requests.len(), 2); let body = requests[1].body_json::().unwrap(); let input = body["input"].as_array().expect("input array"); @@ -743,8 +750,10 @@ where let mut config = load_default_config_for_test(codex_home); config.model_provider = model_provider; mutator(&mut config); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); conversation_manager .new_conversation(config) .await @@ -770,8 +779,10 @@ where let mut config = load_default_config_for_test(codex_home); config.model_provider = model_provider; mutator(&mut config); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); conversation_manager diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index cc653a9c56e..ef2fc16ede0 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -487,9 +487,13 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { // Chat Completions assertion: the second POST should include a tool role message // with an array `content` containing an item with the expected data URL. - let requests = server.received_requests().await.expect("requests captured"); + let all_requests = server.received_requests().await.expect("requests captured"); + let requests: Vec<_> = all_requests + .iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) + .collect(); assert!(requests.len() >= 2, "expected two chat completion calls"); - let second = &requests[1]; + let second = requests[1]; let body: Value = serde_json::from_slice(&second.body)?; let messages = body .get("messages") diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index e2dcb0c5679..15ce32e53f1 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -18,6 +18,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::get_responses_request_bodies; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -1240,10 +1241,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let metadata = outputs @@ -1347,10 +1345,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs @@ -1475,10 +1470,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1727,10 +1719,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1864,10 +1853,7 @@ PY let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1976,10 +1962,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -2065,10 +2048,7 @@ PY let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let large_output = outputs.get(call_id).expect("missing large output summary"); @@ -2145,10 +2125,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs.get(call_id).expect("missing output"); @@ -2246,10 +2223,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let startup_output = outputs @@ -2339,10 +2313,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs.get(call_id).expect("missing output"); diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 964cc58d506..8472399ce42 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -42,8 +42,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); - let conversation_manager = - ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + codex_core::CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -99,8 +101,10 @@ async fn user_shell_cmd_can_be_interrupted() { // Set up isolated config and conversation. let codex_home = TempDir::new().unwrap(); let config = load_default_config_for_test(&codex_home); - let conversation_manager = - ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + codex_core::CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. 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 6eec8b71fcf..1da0796a752 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -140,7 +140,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { VERSION ); - let mut entries = create_config_summary_entries(config); + let mut entries = + create_config_summary_entries(config, session_configured_event.model.as_str()); entries.push(( "session id", session_configured_event.session_id.to_string(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 7dfeeecf7b9..7d7d4c301fb 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -263,7 +263,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let default_cwd = config.cwd.to_path_buf(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -278,6 +277,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config.cli_auth_credentials_store_mode, ); let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec); + let default_model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; // Handle resume subcommand by resolving a rollout path and using explicit resume API. let NewConversation { diff --git a/codex-rs/lmstudio/src/lib.rs b/codex-rs/lmstudio/src/lib.rs index bb8c8cef6a3..fd4f82a728a 100644 --- a/codex-rs/lmstudio/src/lib.rs +++ b/codex-rs/lmstudio/src/lib.rs @@ -11,7 +11,10 @@ pub const DEFAULT_OSS_MODEL: &str = "openai/gpt-oss-20b"; /// - Ensures a local LM Studio server is reachable. /// - Checks if the model exists locally and downloads it if missing. pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { - let model: &str = config.model.as_ref(); + let model = match config.model.as_ref() { + Some(model) => model, + None => DEFAULT_OSS_MODEL, + }; // Verify local LM Studio is reachable. let lmstudio_client = LMStudioClient::try_from_provider(config).await?; diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs index 0ebf1662ac2..4ced3b62760 100644 --- a/codex-rs/ollama/src/lib.rs +++ b/codex-rs/ollama/src/lib.rs @@ -19,7 +19,10 @@ pub const DEFAULT_OSS_MODEL: &str = "gpt-oss:20b"; /// - Checks if the model exists locally and pulls it if missing. pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { // Only download when the requested model is the default OSS model (or when -m is not provided). - let model = config.model.as_ref(); + let model = match config.model.as_ref() { + Some(model) => model, + None => DEFAULT_OSS_MODEL, + }; // Verify local Ollama is reachable. let ollama_client = crate::OllamaClient::try_from_oss_provider(config).await?; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0a09b15e7c8..1ce3b4fd519 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -123,14 +123,15 @@ fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> Optio async fn handle_model_migration_prompt_if_needed( tui: &mut tui::Tui, config: &mut Config, + model: &str, app_event_tx: &AppEventSender, auth_mode: Option, models_manager: Arc, ) -> Option { - let available_models = models_manager.list_models().await; + let available_models = models_manager.list_models(config).await; let upgrade = available_models .iter() - .find(|preset| preset.model == config.model) + .find(|preset| preset.model == model) .and_then(|preset| preset.upgrade.as_ref()); if let Some(ModelUpgrade { @@ -146,7 +147,7 @@ async fn handle_model_migration_prompt_if_needed( let target_model = target_model.to_string(); let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str()); if !should_show_model_migration_prompt( - &config.model, + model, &target_model, hide_prompt_flag, available_models.clone(), @@ -160,7 +161,7 @@ async fn handle_model_migration_prompt_if_needed( app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { migration_config: migration_config_key.to_string(), }); - config.model = target_model.to_string(); + config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -207,6 +208,7 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, + pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -269,9 +271,14 @@ impl App { auth_manager.clone(), SessionSource::Cli, )); + let mut model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; let exit_info = handle_model_migration_prompt_if_needed( tui, &mut config, + model.as_str(), &app_event_tx, auth_mode, conversation_manager.get_models_manager(), @@ -280,6 +287,9 @@ impl App { if let Some(exit_info) = exit_info { return Ok(exit_info); } + if let Some(updated_model) = config.model.clone() { + model = updated_model; + } let skills_outcome = load_skills(&config); if !skills_outcome.errors.is_empty() { @@ -304,7 +314,7 @@ impl App { let enhanced_keys_supported = tui.enhanced_keys_supported(); let model_family = conversation_manager .get_models_manager() - .construct_model_family(&config.model, &config) + .construct_model_family(model.as_str(), &config) .await; let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { @@ -320,7 +330,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, - model_family, + model_family: model_family.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -347,7 +357,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, - model_family, + model_family: model_family.clone(), }; ChatWidget::new_from_existing( init, @@ -369,6 +379,7 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, + current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -489,7 +500,7 @@ impl App { let model_family = self .server .get_models_manager() - .construct_model_family(&self.config.model, &self.config) + .construct_model_family(self.current_model.as_str(), &self.config) .await; match event { AppEvent::NewSession => { @@ -510,9 +521,10 @@ impl App { feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, - model_family, + model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -567,6 +579,7 @@ impl App { resumed.conversation, resumed.session_configured, ); + self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -695,7 +708,7 @@ impl App { .construct_model_family(&model, &self.config) .await; self.chat_widget.set_model(&model, model_family); - self.config.model = model; + self.current_model = model; } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1167,9 +1180,11 @@ mod tests { fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender(); let config = chat_widget.config_ref().clone(); - let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "Test API Key", - ))); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); @@ -1180,6 +1195,7 @@ mod tests { chat_widget, auth_manager, config, + current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1204,9 +1220,11 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender(); let config = chat_widget.config_ref().clone(); - let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "Test API Key", - ))); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); @@ -1218,6 +1236,7 @@ mod tests { chat_widget, auth_manager, config, + current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1343,6 +1362,7 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), + app.current_model.as_str(), event, is_first, )) as Arc diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index ca9de52e2a5..deb629765a2 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -338,9 +338,10 @@ impl App { ) { let conv = new_conv.conversation; let session_configured = new_conv.session_configured; + let model_family = self.chat_widget.get_model_family(); let init = crate::chatwidget::ChatWidgetInit { config: cfg, - model_family: self.chat_widget.get_model_family(), + model_family: model_family.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), initial_prompt: None, @@ -354,6 +355,7 @@ impl App { }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); + self.current_model = model_family.get_model_slug().to_string(); // Trim transcript up to the selected user message and re-render it. self.trim_transcript_for_backtrack(nth_user_message); self.render_transcript_once(tui); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f9e53c80552..ea29c00d937 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -399,6 +399,7 @@ impl ChatWidget { self.session_header.set_model(&model_for_header); self.add_to_history(history_cell::new_session_info( &self.config, + &model_for_header, event, self.show_welcome_banner, )); @@ -625,7 +626,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.config.model != NUDGE_MODEL_SLUG + && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1265,6 +1266,9 @@ impl ChatWidget { is_first_run, model_family, } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut config = config; + config.model = Some(model_slug.clone()); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); @@ -1284,11 +1288,11 @@ impl ChatWidget { skills, }), active_cell: None, - config: config.clone(), + config, model_family, auth_manager, models_manager, - session_header: SessionHeader::new(config.model), + session_header: SessionHeader::new(model_slug), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -1348,6 +1352,7 @@ impl ChatWidget { model_family, .. } = common; + let model_slug = model_family.get_model_slug().to_string(); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1369,11 +1374,11 @@ impl ChatWidget { skills, }), active_cell: None, - config: config.clone(), + config, model_family, auth_manager, models_manager, - session_header: SessionHeader::new(config.model), + session_header: SessionHeader::new(model_slug), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -2035,6 +2040,7 @@ impl ChatWidget { self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), + self.model_family.get_model_slug(), )); } fn stop_rate_limit_poller(&mut self) { @@ -2177,7 +2183,7 @@ impl ChatWidget { /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { - let current_model = self.config.model.clone(); + let current_model = self.model_family.get_model_slug().to_string(); let presets: Vec = // todo(aibrahim): make this async function match self.models_manager.try_list_models() { @@ -2284,7 +2290,7 @@ impl ChatWidget { return; } - let current_model = self.config.model.clone(); + let current_model = self.model_family.get_model_slug().to_string(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { let description = @@ -2413,7 +2419,7 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.config.model == preset.model; + let is_current_model = self.model_family.get_model_slug() == preset.model; let highlight_choice = if is_current_model { self.config.model_reasoning_effort } else { @@ -2970,7 +2976,6 @@ impl ChatWidget { /// Set the model in the widget's config copy. pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { self.session_header.set_model(model); - self.config.model = model.to_string(); self.model_family = model_family; } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 23554932503..c54f0da3d5a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -75,6 +75,7 @@ fn set_windows_sandbox_enabled(enabled: bool) { fn test_config() -> Config { // Use base defaults to avoid depending on host state. + Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), @@ -346,10 +347,12 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config(); - let model_family = ModelsManager::construct_model_family_offline(&cfg.model, &cfg); - let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "test", - ))); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg); + let conversation_manager = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + cfg.model_provider.clone(), + )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let init = ChatWidgetInit { config: cfg, @@ -382,8 +385,11 @@ fn make_chatwidget_manual( let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); let mut cfg = test_config(); + let resolved_model = model_override + .map(str::to_owned) + .unwrap_or_else(|| ModelsManager::get_model_offline(cfg.model.as_deref())); if let Some(model) = model_override { - cfg.model = model.to_string(); + cfg.model = Some(model.to_string()); } let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), @@ -402,10 +408,10 @@ fn make_chatwidget_manual( bottom_pane: bottom, active_cell: None, config: cfg.clone(), - model_family: ModelsManager::construct_model_family_offline(&cfg.model, &cfg), + model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(auth_manager)), - session_header: SessionHeader::new(cfg.model), + session_header: SessionHeader::new(resolved_model.clone()), initial_user_message: None, token_info: None, rate_limit_snapshot: None, @@ -650,10 +656,9 @@ fn rate_limit_snapshot_updates_and_retains_plan_type() { #[test] fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { - let (mut chat, _, _) = make_chatwidget_manual(None); + let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.config.model = NUDGE_MODEL_SLUG.to_string(); chat.on_rate_limit_snapshot(Some(snapshot(95.0))); @@ -666,8 +671,7 @@ fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { #[test] fn rate_limit_switch_prompt_shows_once_per_session() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(None); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.on_rate_limit_snapshot(Some(snapshot(90.0))); @@ -691,8 +695,7 @@ fn rate_limit_switch_prompt_shows_once_per_session() { #[test] fn rate_limit_switch_prompt_respects_hidden_notice() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(None); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.config.notices.hide_rate_limit_model_nudge = Some(true); @@ -707,8 +710,7 @@ fn rate_limit_switch_prompt_respects_hidden_notice() { #[test] fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(None); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.bottom_pane.set_task_running(true); @@ -728,10 +730,9 @@ fn rate_limit_switch_prompt_defers_until_task_complete() { #[test] fn rate_limit_switch_prompt_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.config.model = "gpt-5".to_string(); chat.on_rate_limit_snapshot(Some(snapshot(92.0))); chat.maybe_show_pending_rate_limit_prompt(); @@ -1774,9 +1775,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { #[test] fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); - - chat.config.model = "gpt-5-codex".to_string(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")); chat.open_model_popup(); let popup = render_bottom_popup(&chat, 80); @@ -1879,10 +1878,9 @@ fn startup_prompts_for_windows_sandbox_when_agent_requested() { #[test] fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); @@ -1894,10 +1892,9 @@ fn model_reasoning_selection_popup_snapshot() { #[test] fn model_reasoning_selection_popup_extra_high_warning_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); @@ -1909,10 +1906,9 @@ fn model_reasoning_selection_popup_extra_high_warning_snapshot() { #[test] fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -1992,9 +1988,7 @@ fn feedback_upload_consent_popup_snapshot() { #[test] fn reasoning_popup_escape_returns_to_model_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); - - chat.config.model = "gpt-5.1".to_string(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")); chat.open_model_popup(); let preset = get_available_model(&chat, "gpt-5.1-codex"); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 945ed1f4916..41470673668 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -621,6 +621,7 @@ impl HistoryCell for SessionInfoCell { pub(crate) fn new_session_info( config: &Config, + requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, ) -> SessionInfoCell { @@ -679,10 +680,10 @@ pub(crate) fn new_session_info( { parts.push(Box::new(tooltips)); } - if config.model != model { + if requested_model != model { let lines = vec![ "model changed:".magenta().bold().into(), - format!("requested: {}", config.model).into(), + format!("requested: {requested_model}").into(), format!("used: {model}").into(), ]; parts.push(Box::new(PlainHistoryCell { lines })); @@ -2321,10 +2322,7 @@ mod tests { } #[test] fn reasoning_summary_block() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), reasoning_format, @@ -2339,10 +2337,7 @@ mod tests { #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "Detailed reasoning goes here.".to_string(), reasoning_format, @@ -2355,10 +2350,11 @@ mod tests { #[test] fn reasoning_summary_block_respects_config_overrides() { let mut config = test_config(); - config.model = "gpt-3.5-turbo".to_string(); + config.model = Some("gpt-3.5-turbo".to_string()); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary_format = Some(ReasoningSummaryFormat::Experimental); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = + ModelsManager::construct_model_family_offline(&config.model.clone().unwrap(), &config); assert_eq!( model_family.reasoning_summary_format, ReasoningSummaryFormat::Experimental @@ -2375,10 +2371,7 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_header_is_missing() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning without closing".to_string(), reasoning_format, @@ -2390,10 +2383,7 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_summary_is_missing() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning without closing**".to_string(), reasoning_format.clone(), @@ -2413,10 +2403,7 @@ mod tests { #[test] fn reasoning_summary_block_splits_header_and_summary_when_present() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level plan**\n\nWe should fix the bug next.".to_string(), reasoning_format, diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 7049d13fff1..aac981c764e 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -78,6 +78,7 @@ pub(crate) fn new_status_output( rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, + model_name: &str, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -90,6 +91,7 @@ pub(crate) fn new_status_output( rate_limits, plan_type, now, + model_name, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -107,9 +109,10 @@ impl StatusHistoryCell { rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, + model_name: &str, ) -> Self { - let config_entries = create_config_summary_entries(config); - let (model_name, model_details) = compose_model_display(config, &config_entries); + let config_entries = create_config_summary_entries(config, model_name); + let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() .find(|(k, _)| *k == "approval") diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index cb6b7b54b29..8ba7ec37751 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -17,7 +17,7 @@ fn normalize_agents_display_path(path: &Path) -> String { } pub(crate) fn compose_model_display( - config: &Config, + model_name: &str, entries: &[(&str, String)], ) -> (String, Vec) { let mut details: Vec = Vec::new(); @@ -33,7 +33,7 @@ pub(crate) fn compose_model_display( } } - (config.model.clone(), details) + (model_name.to_string(), details) } pub(crate) fn compose_agents_summary(config: &Config) -> String { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 1b16453c421..53c728526a2 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -39,8 +39,8 @@ fn test_auth_manager(config: &Config) -> AuthManager { ) } -fn test_model_family(config: &Config) -> ModelFamily { - ModelsManager::construct_model_family_offline(config.model.as_str(), config) +fn test_model_family(model_slug: &str, config: &Config) -> ModelFamily { + ModelsManager::construct_model_family_offline(model_slug, config) } fn render_lines(lines: &[Line<'static>]) -> Vec { @@ -88,7 +88,7 @@ fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> fn status_snapshot_includes_reasoning_details() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; @@ -130,7 +130,8 @@ fn status_snapshot_includes_reasoning_details() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, @@ -142,6 +143,7 @@ fn status_snapshot_includes_reasoning_details() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -157,7 +159,7 @@ fn status_snapshot_includes_reasoning_details() { fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.cwd = PathBuf::from("/workspace/tests"); @@ -186,7 +188,8 @@ fn status_snapshot_includes_monthly_limit() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -197,6 +200,7 @@ fn status_snapshot_includes_monthly_limit() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -229,7 +233,8 @@ fn status_snapshot_shows_unlimited_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -240,6 +245,7 @@ fn status_snapshot_shows_unlimited_credits() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -271,7 +277,8 @@ fn status_snapshot_shows_positive_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -282,6 +289,7 @@ fn status_snapshot_shows_positive_credits() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -313,7 +321,8 @@ fn status_snapshot_hides_zero_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -324,6 +333,7 @@ fn status_snapshot_hides_zero_credits() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -353,7 +363,8 @@ fn status_snapshot_hides_when_has_no_credits_flag() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -364,6 +375,7 @@ fn status_snapshot_hides_when_has_no_credits_flag() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -376,7 +388,7 @@ fn status_snapshot_hides_when_has_no_credits_flag() { fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -393,7 +405,8 @@ fn status_card_token_usage_excludes_cached_tokens() { .single() .expect("timestamp"); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -404,6 +417,7 @@ fn status_card_token_usage_excludes_cached_tokens() { None, None, now, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); @@ -417,7 +431,7 @@ fn status_card_token_usage_excludes_cached_tokens() { fn status_snapshot_truncates_in_narrow_terminal() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; @@ -448,7 +462,8 @@ fn status_snapshot_truncates_in_narrow_terminal() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -459,6 +474,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -475,7 +491,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -492,7 +508,8 @@ fn status_snapshot_shows_missing_limits_message() { .single() .expect("timestamp"); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -503,6 +520,7 @@ fn status_snapshot_shows_missing_limits_message() { None, None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -518,7 +536,7 @@ fn status_snapshot_shows_missing_limits_message() { fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex".to_string(); + config.model = Some("gpt-5.1-codex".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -554,7 +572,8 @@ fn status_snapshot_includes_credits_and_limits() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -565,6 +584,7 @@ fn status_snapshot_includes_credits_and_limits() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -580,7 +600,7 @@ fn status_snapshot_includes_credits_and_limits() { fn status_snapshot_shows_empty_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -604,7 +624,8 @@ fn status_snapshot_shows_empty_limits_message() { .expect("timestamp"); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -615,6 +636,7 @@ fn status_snapshot_shows_empty_limits_message() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -630,7 +652,7 @@ fn status_snapshot_shows_empty_limits_message() { fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -663,7 +685,8 @@ fn status_snapshot_shows_stale_limits_message() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -674,6 +697,7 @@ fn status_snapshot_shows_stale_limits_message() { Some(&rate_display), None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -689,7 +713,7 @@ fn status_snapshot_shows_stale_limits_message() { fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex".to_string(); + config.model = Some("gpt-5.1-codex".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -726,7 +750,8 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -737,6 +762,7 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&rate_display), None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -775,7 +801,8 @@ fn status_context_window_uses_last_usage() { .single() .expect("timestamp"); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -786,6 +813,7 @@ fn status_context_window_uses_last_usage() { None, None, now, + &model_slug, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines From 1a5809624d3909128d016fa3a28ff38f217fbc9c Mon Sep 17 00:00:00 2001 From: Robby He <448523760@qq.com> Date: Thu, 11 Dec 2025 03:38:15 +0800 Subject: [PATCH 76/94] fix: Prevent slash command popup from activating on invalid inputs (#7704) ## Slash Command popup issue #7659 When recalling history, the composer(`codex_tui::bottom_pane::chat_composer`) restores the previous prompt text (which may start with `/`) and then calls `sync_command_popup`. The logic in `sync_command_popup` treats any first line that starts with `/` and has the caret inside the initial `/name` token as an active slash command name: ```rust let is_editing_slash_command_name = if first_line.starts_with('/') && caret_on_first_line { let token_end = first_line .char_indices() .find(|(_, c)| c.is_whitespace()) .map(|(i, _)| i) .unwrap_or(first_line.len()); cursor <= token_end } else { false }; ``` This detection does not distinguish between an actual interactive slash command being typed and a normal historical prompt that happens to begin with `/`. As a result, after history recall, the restored prompt like `/ test` is interpreted as an "editing command name" context and the slash-command popup is (re)activated. Once `active_popup` is `ActivePopup::Command`, subsequent `Up` key presses are handled by `handle_key_event_with_slash_popup` instead of `handle_key_event_without_popup`, so they no longer trigger `history.navigate_up(...)` and the session prompt history cannot be scrolled. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 136 ++++++++++++++++-- 1 file changed, 125 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4deb5125c12..ed498e949c6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1579,6 +1579,56 @@ impl ChatComposer { } } + /// If the cursor is currently within a slash command on the first line, + /// extract the command name and the rest of the line after it. + /// Returns None if the cursor is outside a slash command. + fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { + if !first_line.starts_with('/') { + return None; + } + + let name_start = 1usize; + let name_end = first_line[name_start..] + .find(char::is_whitespace) + .map(|idx| name_start + idx) + .unwrap_or_else(|| first_line.len()); + + if cursor > name_end { + return None; + } + + let name = &first_line[name_start..name_end]; + let rest_start = first_line[name_end..] + .find(|c: char| !c.is_whitespace()) + .map(|idx| name_end + idx) + .unwrap_or(name_end); + let rest = &first_line[rest_start..]; + + Some((name, rest)) + } + + /// Heuristic for whether the typed slash command looks like a valid + /// prefix for any known command (built-in or custom prompt). + /// Empty names only count when there is no extra content after the '/'. + fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { + if name.is_empty() { + return rest_after_name.is_empty(); + } + + let builtin_match = built_in_slash_commands() + .into_iter() + .any(|(cmd_name, _)| cmd_name.starts_with(name)); + + if builtin_match { + return true; + } + + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + self.custom_prompts + .iter() + .any(|p| format!("{prompt_prefix}{}", p.name).starts_with(name)) + } + /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. @@ -1596,17 +1646,10 @@ impl ChatComposer { let cursor = self.textarea.cursor(); let caret_on_first_line = cursor <= first_line_end; - let is_editing_slash_command_name = if first_line.starts_with('/') && caret_on_first_line { - // Compute the end of the initial '/name' token (name may be empty yet). - let token_end = first_line - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(i, _)| i) - .unwrap_or(first_line.len()); - cursor <= token_end - } else { - false - }; + let is_editing_slash_command_name = caret_on_first_line + && Self::slash_command_under_cursor(first_line, cursor) + .is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest)); + // If the cursor is currently positioned within an `@token`, prefer the // file-search popup over the slash popup so users can insert a file path // as an argument to the command (e.g., "/review @docs/..."). @@ -3873,4 +3916,75 @@ mod tests { assert_eq!(composer.textarea.text(), "z".repeat(count)); assert!(composer.pending_pastes.is_empty()); } + + #[test] + fn slash_popup_not_activated_for_slash_space_text_history_like_input() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use tokio::sync::mpsc::unbounded_channel; + + 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, + ); + + // Simulate history-like content: "/ test" + composer.set_text_content("/ test".to_string()); + + // After set_text_content -> sync_popups is called; popup should NOT be Command. + assert!( + matches!(composer.active_popup, ActivePopup::None), + "expected no slash popup for '/ test'" + ); + + // Up should be handled by history navigation path, not slash popup handler. + let (result, _redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + } + + #[test] + fn slash_popup_activated_for_bare_slash_and_valid_prefixes() { + // use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use tokio::sync::mpsc::unbounded_channel; + + 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, + ); + + // Case 1: bare "/" + composer.set_text_content("/".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::Command(_)), + "bare '/' should activate slash popup" + ); + + // Case 2: valid prefix "/re" (matches /review, /resume, etc.) + composer.set_text_content("/re".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::Command(_)), + "'/re' should activate slash popup via prefix match" + ); + + // Case 3: invalid prefix "/zzz" – still allowed to open popup if it + // matches no built-in command, our current logic will *not* open popup. + // Verify that explicitly. + composer.set_text_content("/zzz".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::None), + "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" + ); + } } From 4953b2ae09d4999c78295bf3460a3a49b58ed3e5 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 10 Dec 2025 12:15:39 -0800 Subject: [PATCH 77/94] Error when trying to push a release while another release is in progress (#7834) image Currently, we just cancel the in progress release which can be annoying --- codex-rs/scripts/create_github_release | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release index e7b8972db5c..fffd987bc15 100755 --- a/codex-rs/scripts/create_github_release +++ b/codex-rs/scripts/create_github_release @@ -59,6 +59,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace: def main(argv: list[str]) -> int: args = parse_args(argv) + ensure_release_not_in_progress() + # Strip the leading "v" if present. promote_alpha = args.promote_alpha if promote_alpha and promote_alpha.startswith("v"): @@ -144,6 +146,36 @@ def run_gh_api(endpoint: str, *, method: str = "GET", payload: dict | None = Non raise ReleaseError("Failed to parse response from gh api.") from error +def ensure_release_not_in_progress() -> None: + """Fail fast if a release workflow is already running or queued.""" + + statuses = ("in_progress", "queued") + runs: list[dict] = [] + for status in statuses: + response = run_gh_api( + f"/repos/{REPO}/actions/workflows/rust-release.yml/runs?per_page=50&status={status}" + ) + runs.extend(response.get("workflow_runs", [])) + + active_runs = [run for run in runs if run.get("status") in statuses] + if not active_runs: + return + + seen_ids: set[int] = set() + urls: list[str] = [] + for run in active_runs: + run_id = run.get("id") + if run_id in seen_ids: + continue + seen_ids.add(run_id) + urls.append(run.get("html_url", str(run_id))) + + raise ReleaseError( + "Release workflow already running or queued; wait or cancel it before publishing: " + + ", ".join(urls) + ) + + def get_branch_head() -> str: response = run_gh_api(f"/repos/{REPO}/git/refs/{BRANCH_REF}") try: From bfb4d5710b883df074ff3af8da3766dca83cfd2f Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 10 Dec 2025 13:35:31 -0800 Subject: [PATCH 78/94] [app-server-protocol] Add types for config (#7658) Currently the config returned by `config/read` in untyped. Add types so it's easier for client to parse the config. Since currently configs are all defined in snake case we'll keep that instead of using camel case like the rest of V2. Sample output by testing using the app server test client: ``` { < "id": "f28449f4-b015-459b-b07b-eef06980165d", < "result": { < "config": { < "approvalPolicy": null, < "compactPrompt": null, < "developerInstructions": null, < "features": { < "experimental_use_rmcp_client": true < }, < "forcedChatgptWorkspaceId": null, < "forcedLoginMethod": null, < "instructions": null, < "model": "gpt-5.1-codex-max", < "modelAutoCompactTokenLimit": null, < "modelContextWindow": null, < "modelProvider": null, < "modelReasoningEffort": null, < "modelReasoningSummary": null, < "modelVerbosity": null, < "model_providers": { < "local": { < "base_url": "http://localhost:8061/api/codex", < "env_http_headers": { < "ChatGPT-Account-ID": "OPENAI_ACCOUNT_ID" < }, < "env_key": "CHATGPT_TOKEN_STAGING", < "name": "local", < "wire_api": "responses" < } < }, < "model_reasoning_effort": "medium", < "notice": { < "hide_gpt-5.1-codex-max_migration_prompt": true, < "hide_gpt5_1_migration_prompt": true < }, < "profile": null, < "profiles": {}, < "projects": { < "/Users/celia/code": { < "trust_level": "trusted" < }, < "/Users/celia/code/codex": { < "trust_level": "trusted" < }, < "/Users/celia/code/openai": { < "trust_level": "trusted" < } < }, < "reviewModel": null, < "sandboxMode": null, < "sandboxWorkspaceWrite": null, < "tools": { < "viewImage": null, < "webSearch": null < } < }, < "origins": { < "features.experimental_use_rmcp_client": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.base_url": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.env_http_headers.ChatGPT-Account-ID": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.env_key": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.name": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.wire_api": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_reasoning_effort": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "notice.hide_gpt-5.1-codex-max_migration_prompt": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "notice.hide_gpt5_1_migration_prompt": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "projects./Users/celia/code.trust_level": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "projects./Users/celia/code/codex.trust_level": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "projects./Users/celia/code/openai.trust_level": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "tools.web_search": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < } < } < } < } ``` --- .../app-server-protocol/src/protocol/v2.rs | 139 ++++++++++++++++-- codex-rs/app-server/src/config_api.rs | 19 ++- .../app-server/tests/suite/v2/config_rpc.rs | 114 +++++++++----- 3 files changed, 222 insertions(+), 50 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 211f0ba3757..edd3aefa745 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,7 +4,10 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode as CoreSandboxMode; +use codex_protocol::config_types::Verbosity; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::ResponseItem; @@ -12,6 +15,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; @@ -122,17 +126,68 @@ impl From for CodexErrorInfo { } } -v2_enum_from_core!( - pub enum AskForApproval from codex_protocol::protocol::AskForApproval { - UnlessTrusted, OnFailure, OnRequest, Never +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum AskForApproval { + #[serde(rename = "untrusted")] + #[ts(rename = "untrusted")] + UnlessTrusted, + OnFailure, + OnRequest, + Never, +} + +impl AskForApproval { + pub fn to_core(self) -> CoreAskForApproval { + match self { + AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, + AskForApproval::OnFailure => CoreAskForApproval::OnFailure, + AskForApproval::OnRequest => CoreAskForApproval::OnRequest, + AskForApproval::Never => CoreAskForApproval::Never, + } } -); +} -v2_enum_from_core!( - pub enum SandboxMode from codex_protocol::config_types::SandboxMode { - ReadOnly, WorkspaceWrite, DangerFullAccess +impl From for AskForApproval { + fn from(value: CoreAskForApproval) -> Self { + match value { + CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, + CoreAskForApproval::OnFailure => AskForApproval::OnFailure, + CoreAskForApproval::OnRequest => AskForApproval::OnRequest, + CoreAskForApproval::Never => AskForApproval::Never, + } } -); +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum SandboxMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl SandboxMode { + pub fn to_core(self) -> CoreSandboxMode { + match self { + SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, + SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, + SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, + } + } +} + +impl From for SandboxMode { + fn from(value: CoreSandboxMode) -> Self { + match value { + CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, + CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, + CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, + } + } +} v2_enum_from_core!( pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { @@ -159,6 +214,72 @@ pub enum ConfigLayerName { User, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ToolsV2 { + #[serde(alias = "web_search_request")] + pub web_search: Option, + pub view_image: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ProfileV2 { + pub model: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub chatgpt_base_url: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct Config { + pub model: Option, + pub review_model: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub sandbox_mode: Option, + pub sandbox_workspace_write: Option, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, + pub tools: Option, + pub profile: Option, + #[serde(default)] + pub profiles: HashMap, + pub instructions: Option, + pub developer_instructions: Option, + pub compact_prompt: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -237,7 +358,7 @@ pub struct ConfigReadParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigReadResponse { - pub config: JsonValue, + pub config: Config, pub origins: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub layers: Option>, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 98fe93fb259..c1eaf62d26f 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,5 +1,6 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use codex_app_server_protocol::Config; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -75,8 +76,10 @@ impl ConfigApi { let effective = layers.effective_config(); validate_config(&effective).map_err(|err| internal_error("invalid configuration", err))?; + let config: Config = serde_json::from_value(to_json_value(&effective)) + .map_err(|err| internal_error("failed to deserialize configuration", err))?; let response = ConfigReadResponse { - config: to_json_value(&effective), + config, origins: layers.origins(), layers: params.include_layers.then(|| layers.layers_high_to_low()), }; @@ -773,6 +776,7 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> mod tests { use super::*; use anyhow::Result; + use codex_app_server_protocol::AskForApproval; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -895,10 +899,7 @@ remote_compaction = true .await .expect("response"); - assert_eq!( - response.config.get("approval_policy"), - Some(&json!("never")) - ); + assert_eq!(response.config.approval_policy, Some(AskForApproval::Never)); assert_eq!( response @@ -953,8 +954,10 @@ remote_compaction = true }) .await .expect("read"); - let config_object = read_after.config.as_object().expect("object"); - assert_eq!(config_object.get("approval_policy"), Some(&json!("never"))); + assert_eq!( + read_after.config.approval_policy, + Some(AskForApproval::Never) + ); assert_eq!( read_after .origins @@ -1093,7 +1096,7 @@ remote_compaction = true .await .expect("response"); - assert_eq!(response.config.get("model"), Some(&json!("system"))); + assert_eq!(response.config.model.as_deref(), Some("system")); assert_eq!( response.origins.get("model").expect("origin").name, ConfigLayerName::System diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index eb3ece64b29..b6615ef6679 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -1,6 +1,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::ConfigLayerName; @@ -12,9 +13,12 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ToolsV2; use codex_app_server_protocol::WriteStatus; use pretty_assertions::assert_eq; use serde_json::json; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -57,7 +61,7 @@ sandbox_mode = "workspace-write" layers, } = to_response(resp)?; - assert_eq!(config.get("model"), Some(&json!("gpt-user"))); + assert_eq!(config.model.as_deref(), Some("gpt-user")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerName::User @@ -70,6 +74,64 @@ sandbox_mode = "workspace-write" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_tools() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-user" + +[tools] +web_search = true +view_image = false +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + let tools = config.tools.expect("tools present"); + assert_eq!( + tools, + ToolsV2 { + web_search: Some(true), + view_image: Some(false), + } + ); + assert_eq!( + origins.get("tools.web_search").expect("origin").name, + ConfigLayerName::User + ); + assert_eq!( + origins.get("tools.view_image").expect("origin").name, + ConfigLayerName::User + ); + + let layers = layers.expect("layers present"); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].name, ConfigLayerName::SessionFlags); + assert_eq!(layers[1].name, ConfigLayerName::User); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_system_layer_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; @@ -123,30 +185,29 @@ writable_roots = ["/system"] layers, } = to_response(resp)?; - assert_eq!(config.get("model"), Some(&json!("gpt-system"))); + assert_eq!(config.model.as_deref(), Some("gpt-system")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerName::System ); - assert_eq!(config.get("approval_policy"), Some(&json!("never"))); + assert_eq!(config.approval_policy, Some(AskForApproval::Never)); assert_eq!( origins.get("approval_policy").expect("origin").name, ConfigLayerName::System ); - assert_eq!(config.get("sandbox_mode"), Some(&json!("workspace-write"))); + assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); assert_eq!( origins.get("sandbox_mode").expect("origin").name, ConfigLayerName::User ); - assert_eq!( - config - .get("sandbox_workspace_write") - .and_then(|v| v.get("writable_roots")), - Some(&json!(["/system"])) - ); + let sandbox = config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/system")]); assert_eq!( origins .get("sandbox_workspace_write.writable_roots.0") @@ -155,12 +216,7 @@ writable_roots = ["/system"] ConfigLayerName::System ); - assert_eq!( - config - .get("sandbox_workspace_write") - .and_then(|v| v.get("network_access")), - Some(&json!(true)) - ); + assert!(sandbox.network_access); assert_eq!( origins .get("sandbox_workspace_write.network_access") @@ -242,7 +298,7 @@ model = "gpt-old" ) .await??; let verify: ConfigReadResponse = to_response(verify_resp)?; - assert_eq!(verify.config.get("model"), Some(&json!("gpt-new"))); + assert_eq!(verify.config.model.as_deref(), Some("gpt-new")); Ok(()) } @@ -342,22 +398,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { ) .await??; let read: ConfigReadResponse = to_response(read_resp)?; - assert_eq!( - read.config.get("sandbox_mode"), - Some(&json!("workspace-write")) - ); - assert_eq!( - read.config - .get("sandbox_workspace_write") - .and_then(|v| v.get("writable_roots")), - Some(&json!(["/tmp"])) - ); - assert_eq!( - read.config - .get("sandbox_workspace_write") - .and_then(|v| v.get("network_access")), - Some(&json!(false)) - ); + assert_eq!(read.config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); + let sandbox = read + .config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/tmp")]); + assert!(!sandbox.network_access); Ok(()) } From eb2e5458ccbbe07d446a091a22dfddcb0af13bb2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 10 Dec 2025 13:56:48 -0800 Subject: [PATCH 79/94] Disable ansi codes in tui log file (#7836) --- codex-rs/tui/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d9793a07a04..71a47d1198d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -269,6 +269,7 @@ pub async fn run_main( let file_layer = tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_target(false) + .with_ansi(false) .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_filter(env_filter()); From b36ecb6c3276ad399c8b8510d2f8fe7ef4631c2e Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 10 Dec 2025 13:59:17 -0800 Subject: [PATCH 80/94] Inject SKILL.md when it's explicitly mentioned. (#7763) 1. Skills load once in core at session start; the cached outcome is reused across core and surfaced to TUI via SessionConfigured. 2. TUI detects explicit skill selections, and core injects the matching SKILL.md content into the turn when a selected skill is present. --- codex-rs/core/src/codex.rs | 81 ++++++++++- codex-rs/core/src/event_mapping.rs | 29 ++-- codex-rs/core/src/project_doc.rs | 64 +++++---- codex-rs/core/src/skills/injection.rs | 78 ++++++++++ codex-rs/core/src/skills/mod.rs | 3 + codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/user_instructions.rs | 75 ++++++++++ codex-rs/core/tests/common/test_codex.rs | 18 +++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/skills.rs | 136 ++++++++++++++++++ .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 2 + codex-rs/protocol/src/models.rs | 25 ++-- codex-rs/protocol/src/protocol.rs | 23 +++ codex-rs/protocol/src/user_input.rs | 6 + codex-rs/tui/src/app.rs | 60 ++++---- codex-rs/tui/src/app_backtrack.rs | 1 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 + codex-rs/tui/src/bottom_pane/mod.rs | 9 ++ codex-rs/tui/src/chatwidget.rs | 52 ++++++- codex-rs/tui/src/chatwidget/tests.rs | 2 +- 21 files changed, 584 insertions(+), 88 deletions(-) create mode 100644 codex-rs/core/src/skills/injection.rs create mode 100644 codex-rs/core/tests/suite/skills.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 22570ad1b35..e23e03298d4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -97,6 +97,9 @@ use crate::protocol::ReasoningRawContentDeltaEvent; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; +use crate::protocol::SkillErrorInfo; +use crate::protocol::SkillInfo; +use crate::protocol::SkillLoadOutcomeInfo; use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TokenCountEvent; @@ -109,6 +112,10 @@ use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; use crate::shell_snapshot::ShellSnapshot; +use crate::skills::SkillInjections; +use crate::skills::SkillLoadOutcome; +use crate::skills::build_skill_injections; +use crate::skills::load_skills; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -173,7 +180,31 @@ impl Codex { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - let user_instructions = get_user_instructions(&config).await; + let loaded_skills = if config.features.enabled(Feature::Skills) { + Some(load_skills(&config)) + } else { + None + }; + + if let Some(outcome) = &loaded_skills { + for err in &outcome.errors { + error!( + "failed to load skill {}: {}", + err.path.display(), + err.message + ); + } + } + + let skills_outcome = loaded_skills.clone(); + + let user_instructions = get_user_instructions( + &config, + skills_outcome + .as_ref() + .map(|outcome| outcome.skills.as_slice()), + ) + .await; let exec_policy = load_exec_policy_for_features(&config.features, &config.codex_home) .await @@ -206,6 +237,7 @@ impl Codex { // Generate a unique ID for the lifetime of this Codex session. let session_source_clone = session_configuration.session_source.clone(); + let session = Session::new( session_configuration, config.clone(), @@ -214,6 +246,7 @@ impl Codex { tx_event.clone(), conversation_history, session_source_clone, + skills_outcome.clone(), ) .await .map_err(|e| { @@ -471,6 +504,7 @@ impl Session { } } + #[allow(clippy::too_many_arguments)] async fn new( session_configuration: SessionConfiguration, config: Arc, @@ -479,6 +513,7 @@ impl Session { tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, + skills: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -596,6 +631,7 @@ impl Session { otel_event_manager, models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), + skills: skills.clone(), }; let sess = Arc::new(Session { @@ -611,6 +647,7 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); + let skill_load_outcome = skill_load_outcome_for_client(skills.as_ref()); let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), @@ -625,6 +662,7 @@ impl Session { history_log_id, history_entry_count, initial_messages, + skill_load_outcome, rollout_path, }), }) @@ -1978,6 +2016,30 @@ async fn spawn_review_thread( .await; } +fn skill_load_outcome_for_client( + outcome: Option<&SkillLoadOutcome>, +) -> Option { + outcome.map(|outcome| SkillLoadOutcomeInfo { + skills: outcome + .skills + .iter() + .map(|skill| SkillInfo { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + }) + .collect(), + errors: outcome + .errors + .iter() + .map(|err| SkillErrorInfo { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect(), + }) +} + /// Takes a user message as input and runs a loop where, at each turn, the model /// replies with either: /// @@ -2006,11 +2068,26 @@ pub(crate) async fn run_task( }); sess.send_event(&turn_context, event).await; + let SkillInjections { + items: skill_items, + warnings: skill_warnings, + } = build_skill_injections(&input, sess.services.skills.as_ref()).await; + + for message in skill_warnings { + sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) + .await; + } + let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let response_item: ResponseItem = initial_input_for_turn.clone().into(); sess.record_response_item_and_emit_turn_item(turn_context.as_ref(), response_item) .await; + if !skill_items.is_empty() { + sess.record_conversation_items(&turn_context, &skill_items) + .await; + } + sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; let mut last_agent_message: Option = None; @@ -2860,6 +2937,7 @@ mod tests { otel_event_manager: otel_event_manager.clone(), models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), + skills: None, }; let turn_context = Session::make_turn_context( @@ -2945,6 +3023,7 @@ mod tests { otel_event_manager: otel_event_manager.clone(), models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), + skills: None, }; let turn_context = Arc::new(Session::make_turn_context( diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 6b4bed4db3a..6ab6291a4bb 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -13,6 +13,7 @@ use codex_protocol::user_input::UserInput; use tracing::warn; use uuid::Uuid; +use crate::user_instructions::SkillInstructions; use crate::user_instructions::UserInstructions; use crate::user_shell_command::is_user_shell_command_text; @@ -23,7 +24,9 @@ fn is_session_prefix(text: &str) -> bool { } fn parse_user_message(message: &[ContentItem]) -> Option { - if UserInstructions::is_user_instructions(message) { + if UserInstructions::is_user_instructions(message) + || SkillInstructions::is_skill_instructions(message) + { return None; } @@ -198,14 +201,22 @@ mod tests { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "echo 42".to_string(), - }], - }, - ]; + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\ndemo\nskills/demo/SKILL.md\nbody\n" + .to_string(), + }], + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "echo 42".to_string(), + }], + }, + ]; for item in items { let turn_item = parse_turn_item(&item); diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 43a0034801a..cd05520110f 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -15,7 +15,7 @@ use crate::config::Config; use crate::features::Feature; -use crate::skills::load_skills; +use crate::skills::SkillMetadata; use crate::skills::render_skills_section; use dunce::canonicalize as normalize_path; use std::path::PathBuf; @@ -33,17 +33,12 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// Combines `Config::instructions` and `AGENTS.md` (if present) into a single /// string of instructions. -pub(crate) async fn get_user_instructions(config: &Config) -> Option { +pub(crate) async fn get_user_instructions( + config: &Config, + skills: Option<&[SkillMetadata]>, +) -> Option { let skills_section = if config.features.enabled(Feature::Skills) { - let skills_outcome = load_skills(config); - for err in &skills_outcome.errors { - error!( - "failed to load skill {}: {}", - err.path.display(), - err.message - ); - } - render_skills_section(&skills_outcome.skills) + skills.and_then(render_skills_section) } else { None }; @@ -244,6 +239,7 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use crate::skills::load_skills; use std::fs; use std::path::PathBuf; use tempfile::TempDir; @@ -289,7 +285,7 @@ mod tests { async fn no_doc_file_returns_none() { let tmp = tempfile::tempdir().expect("tempdir"); - let res = get_user_instructions(&make_config(&tmp, 4096, None)).await; + let res = get_user_instructions(&make_config(&tmp, 4096, None), None).await; assert!( res.is_none(), "Expected None when AGENTS.md is absent and no system instructions provided" @@ -303,7 +299,7 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap(); - let res = get_user_instructions(&make_config(&tmp, 4096, None)) + let res = get_user_instructions(&make_config(&tmp, 4096, None), None) .await .expect("doc expected"); @@ -322,7 +318,7 @@ mod tests { let huge = "A".repeat(LIMIT * 2); // 2 KiB fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap(); - let res = get_user_instructions(&make_config(&tmp, LIMIT, None)) + let res = get_user_instructions(&make_config(&tmp, LIMIT, None), None) .await .expect("doc expected"); @@ -354,7 +350,9 @@ mod tests { let mut cfg = make_config(&repo, 4096, None); cfg.cwd = nested; - let res = get_user_instructions(&cfg).await.expect("doc expected"); + let res = get_user_instructions(&cfg, None) + .await + .expect("doc expected"); assert_eq!(res, "root level doc"); } @@ -364,7 +362,7 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); fs::write(tmp.path().join("AGENTS.md"), "something").unwrap(); - let res = get_user_instructions(&make_config(&tmp, 0, None)).await; + let res = get_user_instructions(&make_config(&tmp, 0, None), None).await; assert!( res.is_none(), "With limit 0 the function should return None" @@ -380,7 +378,7 @@ mod tests { const INSTRUCTIONS: &str = "base instructions"; - let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS))) + let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)), None) .await .expect("should produce a combined instruction string"); @@ -397,7 +395,7 @@ mod tests { const INSTRUCTIONS: &str = "some instructions"; - let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS))).await; + let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)), None).await; assert_eq!(res, Some(INSTRUCTIONS.to_string())); } @@ -426,7 +424,9 @@ mod tests { let mut cfg = make_config(&repo, 4096, None); cfg.cwd = nested; - let res = get_user_instructions(&cfg).await.expect("doc expected"); + let res = get_user_instructions(&cfg, None) + .await + .expect("doc expected"); assert_eq!(res, "root doc\n\ncrate doc"); } @@ -439,7 +439,7 @@ mod tests { let cfg = make_config(&tmp, 4096, None); - let res = get_user_instructions(&cfg) + let res = get_user_instructions(&cfg, None) .await .expect("local doc expected"); @@ -461,7 +461,7 @@ mod tests { let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]); - let res = get_user_instructions(&cfg) + let res = get_user_instructions(&cfg, None) .await .expect("fallback doc expected"); @@ -477,7 +477,7 @@ mod tests { let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]); - let res = get_user_instructions(&cfg) + let res = get_user_instructions(&cfg, None) .await .expect("AGENTS.md should win"); @@ -506,9 +506,13 @@ mod tests { "extract from pdfs", ); - let res = get_user_instructions(&cfg) - .await - .expect("instructions expected"); + let skills = load_skills(&cfg); + let res = get_user_instructions( + &cfg, + skills.errors.is_empty().then_some(skills.skills.as_slice()), + ) + .await + .expect("instructions expected"); let expected_path = dunce::canonicalize( cfg.codex_home .join("skills/pdf-processing/SKILL.md") @@ -529,9 +533,13 @@ mod tests { let cfg = make_config(&tmp, 4096, None); create_skill(cfg.codex_home.clone(), "linting", "run clippy"); - let res = get_user_instructions(&cfg) - .await - .expect("instructions expected"); + let skills = load_skills(&cfg); + let res = get_user_instructions( + &cfg, + skills.errors.is_empty().then_some(skills.skills.as_slice()), + ) + .await + .expect("instructions expected"); let expected_path = dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path()) .unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md")); diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs new file mode 100644 index 00000000000..a143fce1f22 --- /dev/null +++ b/codex-rs/core/src/skills/injection.rs @@ -0,0 +1,78 @@ +use std::collections::HashSet; + +use crate::skills::SkillLoadOutcome; +use crate::skills::SkillMetadata; +use crate::user_instructions::SkillInstructions; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use tokio::fs; + +#[derive(Debug, Default)] +pub(crate) struct SkillInjections { + pub(crate) items: Vec, + pub(crate) warnings: Vec, +} + +pub(crate) async fn build_skill_injections( + inputs: &[UserInput], + skills: Option<&SkillLoadOutcome>, +) -> SkillInjections { + if inputs.is_empty() { + return SkillInjections::default(); + } + + let Some(outcome) = skills else { + return SkillInjections::default(); + }; + + let mentioned_skills = collect_explicit_skill_mentions(inputs, &outcome.skills); + if mentioned_skills.is_empty() { + return SkillInjections::default(); + } + + let mut result = SkillInjections { + items: Vec::with_capacity(mentioned_skills.len()), + warnings: Vec::new(), + }; + + for skill in mentioned_skills { + match fs::read_to_string(&skill.path).await { + Ok(contents) => { + result.items.push(ResponseItem::from(SkillInstructions { + name: skill.name, + path: skill.path.to_string_lossy().into_owned(), + contents, + })); + } + Err(err) => { + let message = format!( + "Failed to load skill {} at {}: {err:#}", + skill.name, + skill.path.display() + ); + result.warnings.push(message); + } + } + } + + result +} + +fn collect_explicit_skill_mentions( + inputs: &[UserInput], + skills: &[SkillMetadata], +) -> Vec { + let mut selected: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for input in inputs { + if let UserInput::Skill { name, path } = input + && seen.insert(name.clone()) + && let Some(skill) = skills.iter().find(|s| s.name == *name && s.path == *path) + { + selected.push(skill.clone()); + } + } + + selected +} diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index ebb1490c99f..b2ab935ce53 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,7 +1,10 @@ +pub mod injection; pub mod loader; pub mod model; pub mod render; +pub(crate) use injection::SkillInjections; +pub(crate) use injection::build_skill_injections; pub use loader::load_skills; pub use model::SkillError; pub use model::SkillLoadOutcome; diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 7387bcedae0..0270f3411c8 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -4,6 +4,7 @@ use crate::AuthManager; use crate::RolloutRecorder; use crate::mcp_connection_manager::McpConnectionManager; use crate::openai_models::models_manager::ModelsManager; +use crate::skills::SkillLoadOutcome; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; @@ -24,4 +25,5 @@ pub(crate) struct SessionServices { pub(crate) models_manager: Arc, pub(crate) otel_event_manager: OtelEventManager, pub(crate) tool_approvals: Mutex, + pub(crate) skills: Option, } diff --git a/codex-rs/core/src/user_instructions.rs b/codex-rs/core/src/user_instructions.rs index 61f8d7fde4f..22b5ad7bbe5 100644 --- a/codex-rs/core/src/user_instructions.rs +++ b/codex-rs/core/src/user_instructions.rs @@ -6,6 +6,7 @@ use codex_protocol::models::ResponseItem; pub const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = ""; pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for "; +pub const SKILL_INSTRUCTIONS_PREFIX: &str = " for ResponseItem { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename = "skill_instructions", rename_all = "snake_case")] +pub(crate) struct SkillInstructions { + pub name: String, + pub path: String, + pub contents: String, +} + +impl SkillInstructions { + pub fn is_skill_instructions(message: &[ContentItem]) -> bool { + if let [ContentItem::InputText { text }] = message { + text.starts_with(SKILL_INSTRUCTIONS_PREFIX) + } else { + false + } + } +} + +impl From for ResponseItem { + fn from(si: SkillInstructions) -> Self { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "\n{}\n{}\n{}\n", + si.name, si.path, si.contents + ), + }], + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "developer_instructions", rename_all = "snake_case")] pub(crate) struct DeveloperInstructions { @@ -72,6 +106,7 @@ impl From for ResponseItem { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn test_user_instructions() { @@ -115,4 +150,44 @@ mod tests { } ])); } + + #[test] + fn test_skill_instructions() { + let skill_instructions = SkillInstructions { + name: "demo-skill".to_string(), + path: "skills/demo/SKILL.md".to_string(), + contents: "body".to_string(), + }; + let response_item: ResponseItem = skill_instructions.into(); + + let ResponseItem::Message { role, content, .. } = response_item else { + panic!("expected ResponseItem::Message"); + }; + + assert_eq!(role, "user"); + + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected one InputText content item"); + }; + + assert_eq!( + text, + "\ndemo-skill\nskills/demo/SKILL.md\nbody\n", + ); + } + + #[test] + fn test_is_skill_instructions() { + assert!(SkillInstructions::is_skill_instructions(&[ + ContentItem::InputText { + text: "\ndemo-skill\nskills/demo/SKILL.md\nbody\n" + .to_string(), + } + ])); + assert!(!SkillInstructions::is_skill_instructions(&[ + ContentItem::InputText { + text: "regular text".to_string(), + } + ])); + } } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 5f38dbd4b50..b07f4d37412 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -28,6 +28,7 @@ use crate::responses::start_mock_server; use crate::wait_for_event; type ConfigMutator = dyn FnOnce(&mut Config) + Send; +type PreBuildHook = dyn FnOnce(&Path) + Send + 'static; /// A collection of different ways the model can output an apply_patch call #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -51,6 +52,7 @@ pub enum ShellModelOutput { pub struct TestCodexBuilder { config_mutators: Vec>, auth: CodexAuth, + pre_build_hooks: Vec>, } impl TestCodexBuilder { @@ -74,6 +76,14 @@ impl TestCodexBuilder { }) } + pub fn with_pre_build_hook(mut self, hook: F) -> Self + where + F: FnOnce(&Path) + Send + 'static, + { + self.pre_build_hooks.push(Box::new(hook)); + self + } + pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result { let home = Arc::new(TempDir::new()?); self.build_with_home(server, home, None).await @@ -137,6 +147,9 @@ impl TestCodexBuilder { let mut config = load_default_config_for_test(home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; + for hook in self.pre_build_hooks.drain(..) { + hook(home.path()); + } if let Ok(cmd) = assert_cmd::Command::cargo_bin("codex") { config.codex_linux_sandbox_exe = Some(PathBuf::from(cmd.get_program().to_os_string())); } @@ -171,6 +184,10 @@ impl TestCodex { self.cwd.path() } + pub fn codex_home_path(&self) -> &Path { + self.config.codex_home.as_path() + } + pub fn workspace_path(&self, rel: impl AsRef) -> PathBuf { self.cwd_path().join(rel) } @@ -351,5 +368,6 @@ pub fn test_codex() -> TestCodexBuilder { TestCodexBuilder { config_mutators: vec![], auth: CodexAuth::from_api_key("dummy"), + pre_build_hooks: vec![], } } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 29cc3ffb191..e047899d722 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -50,6 +50,7 @@ mod seatbelt; mod shell_command; mod shell_serialization; mod shell_snapshot; +mod skills; mod stream_error_allows_next_turn; mod stream_no_completed; mod text_encoding_fix; diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs new file mode 100644 index 00000000000..d6ced3c1dc3 --- /dev/null +++ b/codex-rs/core/tests/suite/skills.rs @@ -0,0 +1,136 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +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::test_codex::test_codex; +use std::fs; +use std::path::Path; + +fn write_skill(home: &Path, name: &str, description: &str, body: &str) -> std::path::PathBuf { + let skill_dir = home.join("skills").join(name); + fs::create_dir_all(&skill_dir).unwrap(); + let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"); + let path = skill_dir.join("SKILL.md"); + fs::write(&path, contents).unwrap(); + path +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_includes_skill_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let skill_body = "skill body"; + let mut builder = test_codex() + .with_config(|cfg| { + cfg.features.enable(Feature::Skills); + }) + .with_pre_build_hook(|home| { + write_skill(home, "demo", "demo skill", skill_body); + }); + let test = builder.build(&server).await?; + + let skill_path = test.codex_home_path().join("skills/demo/SKILL.md"); + let skill_path = std::fs::canonicalize(skill_path)?; + + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let session_model = test.session_configured.model.clone(); + test.codex + .submit(Op::UserTurn { + items: vec![ + UserInput::Text { + text: "please use $demo".to_string(), + }, + UserInput::Skill { + name: "demo".to_string(), + path: skill_path.clone(), + }, + ], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + }) + .await?; + + core_test_support::wait_for_event(test.codex.as_ref(), |event| { + matches!(event, codex_core::protocol::EventMsg::TaskComplete(_)) + }) + .await; + + let request = mock.single_request(); + let user_texts = request.message_input_texts("user"); + let skill_path_str = skill_path.to_string_lossy(); + assert!( + user_texts.iter().any(|text| { + text.contains("\ndemo") + && text.contains("") + && text.contains(skill_body) + && text.contains(skill_path_str.as_ref()) + }), + "expected skill instructions in user input, got {user_texts:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skill_load_errors_surface_in_session_configured() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_config(|cfg| { + cfg.features.enable(Feature::Skills); + }) + .with_pre_build_hook(|home| { + let skill_dir = home.join("skills").join("broken"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join("SKILL.md"), "not yaml").unwrap(); + }); + let test = builder.build(&server).await?; + + let SkillLoadOutcomeInfo { skills, errors } = test + .session_configured + .skill_load_outcome + .as_ref() + .expect("skill outcome present"); + + assert!( + skills.is_empty(), + "expected no skills loaded, got {skills:?}" + ); + assert_eq!(errors.len(), 1, "expected one load error"); + let error_path = errors[0].path.to_string_lossy(); + assert!( + error_path.ends_with("skills/broken/SKILL.md"), + "unexpected error path: {error_path}" + ); + + Ok(()) +} diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 2b3673f5a6d..2291698d665 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -85,6 +85,7 @@ fn session_configured_produces_thread_started_event() { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path, }), ); diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 83ac25fdfd4..3af472ddb9f 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -266,6 +266,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; @@ -305,6 +306,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; let event = Event { diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 51e977cb958..5c609c3c463 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -300,36 +300,37 @@ impl From> for ResponseInputItem { role: "user".to_string(), content: items .into_iter() - .map(|c| match c { - UserInput::Text { text } => ContentItem::InputText { text }, - UserInput::Image { image_url } => ContentItem::InputImage { image_url }, + .filter_map(|c| match c { + UserInput::Text { text } => Some(ContentItem::InputText { text }), + UserInput::Image { image_url } => Some(ContentItem::InputImage { image_url }), UserInput::LocalImage { path } => match load_and_resize_to_fit(&path) { - Ok(image) => ContentItem::InputImage { + Ok(image) => Some(ContentItem::InputImage { image_url: image.into_data_url(), - }, + }), Err(err) => { if matches!(&err, ImageProcessingError::Read { .. }) { - local_image_error_placeholder(&path, &err) + Some(local_image_error_placeholder(&path, &err)) } else if err.is_invalid_image() { - invalid_image_error_placeholder(&path, &err) + Some(invalid_image_error_placeholder(&path, &err)) } else { let Some(mime_guess) = mime_guess::from_path(&path).first() else { - return local_image_error_placeholder( + return Some(local_image_error_placeholder( &path, "unsupported MIME type (unknown)", - ); + )); }; let mime = mime_guess.essence_str().to_owned(); if !mime.starts_with("image/") { - return local_image_error_placeholder( + return Some(local_image_error_placeholder( &path, format!("unsupported MIME type `{mime}`"), - ); + )); } - unsupported_image_error_placeholder(&path, &mime) + Some(unsupported_image_error_placeholder(&path, &mime)) } } }, + UserInput::Skill { .. } => None, // Skill bodies are injected later in core }) .collect::>(), } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 973fd265821..73e2c9c8771 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1624,6 +1624,25 @@ pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillInfo { + pub name: String, + pub description: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)] +pub struct SkillLoadOutcomeInfo { + pub skills: Vec, + pub errors: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. @@ -1659,6 +1678,9 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub skill_load_outcome: Option, + pub rollout_path: PathBuf, } @@ -1786,6 +1808,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index 881b9965145..26773e1a1a8 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -21,4 +21,10 @@ pub enum UserInput { LocalImage { path: std::path::PathBuf, }, + + /// Skill selected by the user (name + path to SKILL.md). + Skill { + name: String, + path: std::path::PathBuf, + }, } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1ce3b4fd519..a12393c91a1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -25,6 +25,7 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; +#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; @@ -33,9 +34,9 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; +use codex_core::protocol::SkillLoadOutcomeInfo; use codex_core::protocol::TokenUsage; -use codex_core::skills::load_skills; -use codex_core::skills::model::SkillMetadata; +use codex_core::skills::SkillError; use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; @@ -88,6 +89,17 @@ fn session_summary( }) } +fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { + outcome + .errors + .iter() + .map(|err| SkillError { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect() +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -237,8 +249,6 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, - - pub(crate) skills: Option>, } impl App { @@ -291,26 +301,6 @@ impl App { model = updated_model; } - let skills_outcome = load_skills(&config); - if !skills_outcome.errors.is_empty() { - match run_skill_error_prompt(tui, &skills_outcome.errors).await { - SkillErrorPromptOutcome::Exit => { - return Ok(AppExitInfo { - token_usage: TokenUsage::default(), - conversation_id: None, - update_action: None, - }); - } - SkillErrorPromptOutcome::Continue => {} - } - } - - let skills = if config.features.enabled(Feature::Skills) { - Some(skills_outcome.skills.clone()) - } else { - None - }; - let enhanced_keys_supported = tui.enhanced_keys_supported(); let model_family = conversation_manager .get_models_manager() @@ -328,7 +318,6 @@ impl App { auth_manager: auth_manager.clone(), models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), - skills: skills.clone(), is_first_run, model_family: model_family.clone(), }; @@ -355,7 +344,6 @@ impl App { auth_manager: auth_manager.clone(), models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), - skills: skills.clone(), is_first_run, model_family: model_family.clone(), }; @@ -393,7 +381,6 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, - skills, }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -519,7 +506,6 @@ impl App { auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), - skills: self.skills.clone(), is_first_run: false, model_family: model_family.clone(), }; @@ -570,7 +556,6 @@ impl App { auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), - skills: self.skills.clone(), is_first_run: false, model_family: model_family.clone(), }; @@ -662,6 +647,19 @@ impl App { self.suppress_shutdown_complete = false; return Ok(true); } + if let EventMsg::SessionConfigured(cfg) = &event.msg + && let Some(outcome) = cfg.skill_load_outcome.as_ref() + && !outcome.errors.is_empty() + { + let errors = skill_errors_from_outcome(outcome); + match run_skill_error_prompt(tui, &errors).await { + SkillErrorPromptOutcome::Exit => { + self.chat_widget.submit_op(Op::Shutdown); + return Ok(false); + } + SkillErrorPromptOutcome::Continue => {} + } + } self.chat_widget.handle_codex_event(event); } AppEvent::ConversationHistory(ev) => { @@ -1209,7 +1207,6 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, - skills: None, } } @@ -1250,7 +1247,6 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, - skills: None, }, rx, op_rx, @@ -1358,6 +1354,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path: PathBuf::new(), }; Arc::new(new_session_info( @@ -1413,6 +1410,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path: PathBuf::new(), }; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index deb629765a2..671702d3082 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -350,7 +350,6 @@ impl App { auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), - skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ed498e949c6..39b600b155d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -801,6 +801,10 @@ impl ChatComposer { self.skills.as_ref().is_some_and(|s| !s.is_empty()) } + pub fn skills(&self) -> Option<&Vec> { + self.skills.as_ref() + } + /// Extract a token prefixed with `prefix` under the cursor, if any. /// /// The returned string **does not** include the prefix. diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 554810de7f0..85166872840 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -131,10 +131,19 @@ impl BottomPane { } } + pub fn set_skills(&mut self, skills: Option>) { + self.composer.set_skill_mentions(skills); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } + pub fn skills(&self) -> Option<&Vec> { + self.composer.skills() + } + #[cfg(test)] pub(crate) fn context_window_percent(&self) -> Option { self.context_window_percent diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ea29c00d937..6196e04621f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -44,6 +44,7 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; +use codex_core::protocol::SkillLoadOutcomeInfo; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TerminalInteractionEvent; @@ -263,7 +264,6 @@ pub(crate) struct ChatWidgetInit { pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, - pub(crate) skills: Option>, pub(crate) is_first_run: bool, pub(crate) model_family: ModelFamily, } @@ -392,6 +392,7 @@ impl ChatWidget { fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); + self.set_skills_from_outcome(event.skill_load_outcome.as_ref()); self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); @@ -416,6 +417,11 @@ impl ChatWidget { } } + fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) { + let skills = outcome.map(skills_from_outcome); + self.bottom_pane.set_skills(skills); + } + pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -1262,7 +1268,6 @@ impl ChatWidget { auth_manager, models_manager, feedback, - skills, is_first_run, model_family, } = common; @@ -1285,7 +1290,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, - skills, + skills: None, }), active_cell: None, config, @@ -1348,7 +1353,6 @@ impl ChatWidget { auth_manager, models_manager, feedback, - skills, model_family, .. } = common; @@ -1371,7 +1375,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, - skills, + skills: None, }), active_cell: None, config, @@ -1738,6 +1742,16 @@ impl ChatWidget { items.push(UserInput::LocalImage { path }); } + if let Some(skills) = self.bottom_pane.skills() { + let skill_mentions = find_skill_mentions(&text, skills); + for skill in skill_mentions { + items.push(UserInput::Skill { + name: skill.name.clone(), + path: skill.path.clone(), + }); + } + } + self.codex_op_tx .send(Op::UserInput { items }) .unwrap_or_else(|e| { @@ -3459,5 +3473,33 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } +fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { + outcome + .skills + .iter() + .map(|skill| SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + }) + .collect() +} + +fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut matches: Vec = Vec::new(); + for skill in skills { + if seen.contains(&skill.name) { + continue; + } + let needle = format!("${}", skill.name); + if text.contains(&needle) { + seen.insert(skill.name.clone()); + matches.push(skill.clone()); + } + } + matches +} + #[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 c54f0da3d5a..bd85a9edc84 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -122,6 +122,7 @@ fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; @@ -364,7 +365,6 @@ async fn helpers_are_available_and_do_not_panic() { auth_manager, models_manager: conversation_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), - skills: None, is_first_run: true, model_family, }; From 321625072a384f124aceecb876f73f0e0f1e6a7c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 10 Dec 2025 14:01:18 -0800 Subject: [PATCH 81/94] Show the default model in model picker (#7838) See the snapshot --- codex-rs/tui/src/bottom_pane/list_selection_view.rs | 10 +++++++--- codex-rs/tui/src/chatwidget.rs | 6 ++++-- ...i__chatwidget__tests__model_selection_popup.snap | 13 +++++++------ 3 files changed, 18 insertions(+), 11 deletions(-) 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 d23fd8ed3b6..46d6daac601 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -40,6 +40,7 @@ pub(crate) struct SelectionItem { pub description: Option, pub selected_description: Option, pub is_current: bool, + pub is_default: bool, pub actions: Vec, pub dismiss_on_select: bool, pub search_value: Option, @@ -187,11 +188,14 @@ impl ListSelectionView { 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)") + let marker = if item.is_current { + " (current)" + } else if item.is_default { + " (default)" } else { - item.name.clone() + "" }; + let name_with_marker = format!("{name}{marker}"); let n = visible_idx + 1; let wrap_prefix = if self.is_searchable { // The number keys don't work when search is enabled (since we let the diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6196e04621f..82f00f9cacc 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2240,9 +2240,10 @@ impl ChatWidget { Some(preset.default_reasoning_effort), ); SelectionItem { - name: preset.display_name, + name: preset.display_name.clone(), description, is_current: model == current_model, + is_default: preset.is_default, actions, dismiss_on_select: true, ..Default::default() @@ -2319,9 +2320,10 @@ impl ChatWidget { }); })]; items.push(SelectionItem { - name: preset.display_name.to_string(), + name: preset.display_name.clone(), description, is_current, + is_default: preset.is_default, actions, dismiss_on_select: single_supported_effort, ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index 56a209ef73a..a7a1c565199 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -5,11 +5,12 @@ expression: popup Select Model and Effort Access legacy models by running codex -m or in your config.toml -› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast - reasoning. - 2. gpt-5.1-codex Optimized for codex. - 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. - 4. gpt-5.1 Broad world knowledge with strong general reasoning. +› 1. gpt-5.1-codex-max (default) Latest Codex-optimized flagship for deep and + fast reasoning. + 2. gpt-5.1-codex Optimized for codex. + 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but + less capable. + 4. gpt-5.1 Broad world knowledge with strong general + reasoning. Press enter to select reasoning effort, or esc to dismiss. From 90f262e9a46e592a58fe3e2cd6efc8717e448098 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Wed, 10 Dec 2025 14:53:46 -0800 Subject: [PATCH 82/94] feat(tui2): copy tui crate and normalize snapshots (#7833) Introduce a full codex-tui source snapshot under the new codex-tui2 crate so viewport work can be replayed in isolation. This change copies the entire codex-rs/tui/src tree into codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep future diffs vs the original viewport bookmark easy to reason about. The goal is for codex-tui2 to render identically to the existing TUI behind the `features.tui2` flag while we gradually port the viewport/history commits from the joshka/viewport bookmark onto this forked tree. While on this baseline change, we also ran the codex-tui2 snapshot test suite and accepted all insta snapshots for the new crate, so the snapshot files now use the codex-tui2 naming scheme and encode the unmodified legacy TUI behavior. This keeps later viewport commits focused on intentional behavior changes (and their snapshots) rather than on mechanical snapshot renames. --- codex-rs/Cargo.lock | 57 + codex-rs/cli/src/main.rs | 3 +- codex-rs/tui2/Cargo.toml | 91 +- codex-rs/tui2/frames/blocks/frame_1.txt | 17 + codex-rs/tui2/frames/blocks/frame_10.txt | 17 + codex-rs/tui2/frames/blocks/frame_11.txt | 17 + codex-rs/tui2/frames/blocks/frame_12.txt | 17 + codex-rs/tui2/frames/blocks/frame_13.txt | 17 + codex-rs/tui2/frames/blocks/frame_14.txt | 17 + codex-rs/tui2/frames/blocks/frame_15.txt | 17 + codex-rs/tui2/frames/blocks/frame_16.txt | 17 + codex-rs/tui2/frames/blocks/frame_17.txt | 17 + codex-rs/tui2/frames/blocks/frame_18.txt | 17 + codex-rs/tui2/frames/blocks/frame_19.txt | 17 + codex-rs/tui2/frames/blocks/frame_2.txt | 17 + codex-rs/tui2/frames/blocks/frame_20.txt | 17 + codex-rs/tui2/frames/blocks/frame_21.txt | 17 + codex-rs/tui2/frames/blocks/frame_22.txt | 17 + codex-rs/tui2/frames/blocks/frame_23.txt | 17 + codex-rs/tui2/frames/blocks/frame_24.txt | 17 + codex-rs/tui2/frames/blocks/frame_25.txt | 17 + codex-rs/tui2/frames/blocks/frame_26.txt | 17 + codex-rs/tui2/frames/blocks/frame_27.txt | 17 + codex-rs/tui2/frames/blocks/frame_28.txt | 17 + codex-rs/tui2/frames/blocks/frame_29.txt | 17 + codex-rs/tui2/frames/blocks/frame_3.txt | 17 + codex-rs/tui2/frames/blocks/frame_30.txt | 17 + codex-rs/tui2/frames/blocks/frame_31.txt | 17 + codex-rs/tui2/frames/blocks/frame_32.txt | 17 + codex-rs/tui2/frames/blocks/frame_33.txt | 17 + codex-rs/tui2/frames/blocks/frame_34.txt | 17 + codex-rs/tui2/frames/blocks/frame_35.txt | 17 + codex-rs/tui2/frames/blocks/frame_36.txt | 17 + codex-rs/tui2/frames/blocks/frame_4.txt | 17 + codex-rs/tui2/frames/blocks/frame_5.txt | 17 + codex-rs/tui2/frames/blocks/frame_6.txt | 17 + codex-rs/tui2/frames/blocks/frame_7.txt | 17 + codex-rs/tui2/frames/blocks/frame_8.txt | 17 + codex-rs/tui2/frames/blocks/frame_9.txt | 17 + codex-rs/tui2/frames/codex/frame_1.txt | 17 + codex-rs/tui2/frames/codex/frame_10.txt | 17 + codex-rs/tui2/frames/codex/frame_11.txt | 17 + codex-rs/tui2/frames/codex/frame_12.txt | 17 + codex-rs/tui2/frames/codex/frame_13.txt | 17 + codex-rs/tui2/frames/codex/frame_14.txt | 17 + codex-rs/tui2/frames/codex/frame_15.txt | 17 + codex-rs/tui2/frames/codex/frame_16.txt | 17 + codex-rs/tui2/frames/codex/frame_17.txt | 17 + codex-rs/tui2/frames/codex/frame_18.txt | 17 + codex-rs/tui2/frames/codex/frame_19.txt | 17 + codex-rs/tui2/frames/codex/frame_2.txt | 17 + codex-rs/tui2/frames/codex/frame_20.txt | 17 + codex-rs/tui2/frames/codex/frame_21.txt | 17 + codex-rs/tui2/frames/codex/frame_22.txt | 17 + codex-rs/tui2/frames/codex/frame_23.txt | 17 + codex-rs/tui2/frames/codex/frame_24.txt | 17 + codex-rs/tui2/frames/codex/frame_25.txt | 17 + codex-rs/tui2/frames/codex/frame_26.txt | 17 + codex-rs/tui2/frames/codex/frame_27.txt | 17 + codex-rs/tui2/frames/codex/frame_28.txt | 17 + codex-rs/tui2/frames/codex/frame_29.txt | 17 + codex-rs/tui2/frames/codex/frame_3.txt | 17 + codex-rs/tui2/frames/codex/frame_30.txt | 17 + codex-rs/tui2/frames/codex/frame_31.txt | 17 + codex-rs/tui2/frames/codex/frame_32.txt | 17 + codex-rs/tui2/frames/codex/frame_33.txt | 17 + codex-rs/tui2/frames/codex/frame_34.txt | 17 + codex-rs/tui2/frames/codex/frame_35.txt | 17 + codex-rs/tui2/frames/codex/frame_36.txt | 17 + codex-rs/tui2/frames/codex/frame_4.txt | 17 + codex-rs/tui2/frames/codex/frame_5.txt | 17 + codex-rs/tui2/frames/codex/frame_6.txt | 17 + codex-rs/tui2/frames/codex/frame_7.txt | 17 + codex-rs/tui2/frames/codex/frame_8.txt | 17 + codex-rs/tui2/frames/codex/frame_9.txt | 17 + codex-rs/tui2/frames/default/frame_1.txt | 17 + codex-rs/tui2/frames/default/frame_10.txt | 17 + codex-rs/tui2/frames/default/frame_11.txt | 17 + codex-rs/tui2/frames/default/frame_12.txt | 17 + codex-rs/tui2/frames/default/frame_13.txt | 17 + codex-rs/tui2/frames/default/frame_14.txt | 17 + codex-rs/tui2/frames/default/frame_15.txt | 17 + codex-rs/tui2/frames/default/frame_16.txt | 17 + codex-rs/tui2/frames/default/frame_17.txt | 17 + codex-rs/tui2/frames/default/frame_18.txt | 17 + codex-rs/tui2/frames/default/frame_19.txt | 17 + codex-rs/tui2/frames/default/frame_2.txt | 17 + codex-rs/tui2/frames/default/frame_20.txt | 17 + codex-rs/tui2/frames/default/frame_21.txt | 17 + codex-rs/tui2/frames/default/frame_22.txt | 17 + codex-rs/tui2/frames/default/frame_23.txt | 17 + codex-rs/tui2/frames/default/frame_24.txt | 17 + codex-rs/tui2/frames/default/frame_25.txt | 17 + codex-rs/tui2/frames/default/frame_26.txt | 17 + codex-rs/tui2/frames/default/frame_27.txt | 17 + codex-rs/tui2/frames/default/frame_28.txt | 17 + codex-rs/tui2/frames/default/frame_29.txt | 17 + codex-rs/tui2/frames/default/frame_3.txt | 17 + codex-rs/tui2/frames/default/frame_30.txt | 17 + codex-rs/tui2/frames/default/frame_31.txt | 17 + codex-rs/tui2/frames/default/frame_32.txt | 17 + codex-rs/tui2/frames/default/frame_33.txt | 17 + codex-rs/tui2/frames/default/frame_34.txt | 17 + codex-rs/tui2/frames/default/frame_35.txt | 17 + codex-rs/tui2/frames/default/frame_36.txt | 17 + codex-rs/tui2/frames/default/frame_4.txt | 17 + codex-rs/tui2/frames/default/frame_5.txt | 17 + codex-rs/tui2/frames/default/frame_6.txt | 17 + codex-rs/tui2/frames/default/frame_7.txt | 17 + codex-rs/tui2/frames/default/frame_8.txt | 17 + codex-rs/tui2/frames/default/frame_9.txt | 17 + codex-rs/tui2/frames/dots/frame_1.txt | 17 + codex-rs/tui2/frames/dots/frame_10.txt | 17 + codex-rs/tui2/frames/dots/frame_11.txt | 17 + codex-rs/tui2/frames/dots/frame_12.txt | 17 + codex-rs/tui2/frames/dots/frame_13.txt | 17 + codex-rs/tui2/frames/dots/frame_14.txt | 17 + codex-rs/tui2/frames/dots/frame_15.txt | 17 + codex-rs/tui2/frames/dots/frame_16.txt | 17 + codex-rs/tui2/frames/dots/frame_17.txt | 17 + codex-rs/tui2/frames/dots/frame_18.txt | 17 + codex-rs/tui2/frames/dots/frame_19.txt | 17 + codex-rs/tui2/frames/dots/frame_2.txt | 17 + codex-rs/tui2/frames/dots/frame_20.txt | 17 + codex-rs/tui2/frames/dots/frame_21.txt | 17 + codex-rs/tui2/frames/dots/frame_22.txt | 17 + codex-rs/tui2/frames/dots/frame_23.txt | 17 + codex-rs/tui2/frames/dots/frame_24.txt | 17 + codex-rs/tui2/frames/dots/frame_25.txt | 17 + codex-rs/tui2/frames/dots/frame_26.txt | 17 + codex-rs/tui2/frames/dots/frame_27.txt | 17 + codex-rs/tui2/frames/dots/frame_28.txt | 17 + codex-rs/tui2/frames/dots/frame_29.txt | 17 + codex-rs/tui2/frames/dots/frame_3.txt | 17 + codex-rs/tui2/frames/dots/frame_30.txt | 17 + codex-rs/tui2/frames/dots/frame_31.txt | 17 + codex-rs/tui2/frames/dots/frame_32.txt | 17 + codex-rs/tui2/frames/dots/frame_33.txt | 17 + codex-rs/tui2/frames/dots/frame_34.txt | 17 + codex-rs/tui2/frames/dots/frame_35.txt | 17 + codex-rs/tui2/frames/dots/frame_36.txt | 17 + codex-rs/tui2/frames/dots/frame_4.txt | 17 + codex-rs/tui2/frames/dots/frame_5.txt | 17 + codex-rs/tui2/frames/dots/frame_6.txt | 17 + codex-rs/tui2/frames/dots/frame_7.txt | 17 + codex-rs/tui2/frames/dots/frame_8.txt | 17 + codex-rs/tui2/frames/dots/frame_9.txt | 17 + codex-rs/tui2/frames/hash/frame_1.txt | 17 + codex-rs/tui2/frames/hash/frame_10.txt | 17 + codex-rs/tui2/frames/hash/frame_11.txt | 17 + codex-rs/tui2/frames/hash/frame_12.txt | 17 + codex-rs/tui2/frames/hash/frame_13.txt | 17 + codex-rs/tui2/frames/hash/frame_14.txt | 17 + codex-rs/tui2/frames/hash/frame_15.txt | 17 + codex-rs/tui2/frames/hash/frame_16.txt | 17 + codex-rs/tui2/frames/hash/frame_17.txt | 17 + codex-rs/tui2/frames/hash/frame_18.txt | 17 + codex-rs/tui2/frames/hash/frame_19.txt | 17 + codex-rs/tui2/frames/hash/frame_2.txt | 17 + codex-rs/tui2/frames/hash/frame_20.txt | 17 + codex-rs/tui2/frames/hash/frame_21.txt | 17 + codex-rs/tui2/frames/hash/frame_22.txt | 17 + codex-rs/tui2/frames/hash/frame_23.txt | 17 + codex-rs/tui2/frames/hash/frame_24.txt | 17 + codex-rs/tui2/frames/hash/frame_25.txt | 17 + codex-rs/tui2/frames/hash/frame_26.txt | 17 + codex-rs/tui2/frames/hash/frame_27.txt | 17 + codex-rs/tui2/frames/hash/frame_28.txt | 17 + codex-rs/tui2/frames/hash/frame_29.txt | 17 + codex-rs/tui2/frames/hash/frame_3.txt | 17 + codex-rs/tui2/frames/hash/frame_30.txt | 17 + codex-rs/tui2/frames/hash/frame_31.txt | 17 + codex-rs/tui2/frames/hash/frame_32.txt | 17 + codex-rs/tui2/frames/hash/frame_33.txt | 17 + codex-rs/tui2/frames/hash/frame_34.txt | 17 + codex-rs/tui2/frames/hash/frame_35.txt | 17 + codex-rs/tui2/frames/hash/frame_36.txt | 17 + codex-rs/tui2/frames/hash/frame_4.txt | 17 + codex-rs/tui2/frames/hash/frame_5.txt | 17 + codex-rs/tui2/frames/hash/frame_6.txt | 17 + codex-rs/tui2/frames/hash/frame_7.txt | 17 + codex-rs/tui2/frames/hash/frame_8.txt | 17 + codex-rs/tui2/frames/hash/frame_9.txt | 17 + codex-rs/tui2/frames/hbars/frame_1.txt | 17 + codex-rs/tui2/frames/hbars/frame_10.txt | 17 + codex-rs/tui2/frames/hbars/frame_11.txt | 17 + codex-rs/tui2/frames/hbars/frame_12.txt | 17 + codex-rs/tui2/frames/hbars/frame_13.txt | 17 + codex-rs/tui2/frames/hbars/frame_14.txt | 17 + codex-rs/tui2/frames/hbars/frame_15.txt | 17 + codex-rs/tui2/frames/hbars/frame_16.txt | 17 + codex-rs/tui2/frames/hbars/frame_17.txt | 17 + codex-rs/tui2/frames/hbars/frame_18.txt | 17 + codex-rs/tui2/frames/hbars/frame_19.txt | 17 + codex-rs/tui2/frames/hbars/frame_2.txt | 17 + codex-rs/tui2/frames/hbars/frame_20.txt | 17 + codex-rs/tui2/frames/hbars/frame_21.txt | 17 + codex-rs/tui2/frames/hbars/frame_22.txt | 17 + codex-rs/tui2/frames/hbars/frame_23.txt | 17 + codex-rs/tui2/frames/hbars/frame_24.txt | 17 + codex-rs/tui2/frames/hbars/frame_25.txt | 17 + codex-rs/tui2/frames/hbars/frame_26.txt | 17 + codex-rs/tui2/frames/hbars/frame_27.txt | 17 + codex-rs/tui2/frames/hbars/frame_28.txt | 17 + codex-rs/tui2/frames/hbars/frame_29.txt | 17 + codex-rs/tui2/frames/hbars/frame_3.txt | 17 + codex-rs/tui2/frames/hbars/frame_30.txt | 17 + codex-rs/tui2/frames/hbars/frame_31.txt | 17 + codex-rs/tui2/frames/hbars/frame_32.txt | 17 + codex-rs/tui2/frames/hbars/frame_33.txt | 17 + codex-rs/tui2/frames/hbars/frame_34.txt | 17 + codex-rs/tui2/frames/hbars/frame_35.txt | 17 + codex-rs/tui2/frames/hbars/frame_36.txt | 17 + codex-rs/tui2/frames/hbars/frame_4.txt | 17 + codex-rs/tui2/frames/hbars/frame_5.txt | 17 + codex-rs/tui2/frames/hbars/frame_6.txt | 17 + codex-rs/tui2/frames/hbars/frame_7.txt | 17 + codex-rs/tui2/frames/hbars/frame_8.txt | 17 + codex-rs/tui2/frames/hbars/frame_9.txt | 17 + codex-rs/tui2/frames/openai/frame_1.txt | 17 + codex-rs/tui2/frames/openai/frame_10.txt | 17 + codex-rs/tui2/frames/openai/frame_11.txt | 17 + codex-rs/tui2/frames/openai/frame_12.txt | 17 + codex-rs/tui2/frames/openai/frame_13.txt | 17 + codex-rs/tui2/frames/openai/frame_14.txt | 17 + codex-rs/tui2/frames/openai/frame_15.txt | 17 + codex-rs/tui2/frames/openai/frame_16.txt | 17 + codex-rs/tui2/frames/openai/frame_17.txt | 17 + codex-rs/tui2/frames/openai/frame_18.txt | 17 + codex-rs/tui2/frames/openai/frame_19.txt | 17 + codex-rs/tui2/frames/openai/frame_2.txt | 17 + codex-rs/tui2/frames/openai/frame_20.txt | 17 + codex-rs/tui2/frames/openai/frame_21.txt | 17 + codex-rs/tui2/frames/openai/frame_22.txt | 17 + codex-rs/tui2/frames/openai/frame_23.txt | 17 + codex-rs/tui2/frames/openai/frame_24.txt | 17 + codex-rs/tui2/frames/openai/frame_25.txt | 17 + codex-rs/tui2/frames/openai/frame_26.txt | 17 + codex-rs/tui2/frames/openai/frame_27.txt | 17 + codex-rs/tui2/frames/openai/frame_28.txt | 17 + codex-rs/tui2/frames/openai/frame_29.txt | 17 + codex-rs/tui2/frames/openai/frame_3.txt | 17 + codex-rs/tui2/frames/openai/frame_30.txt | 17 + codex-rs/tui2/frames/openai/frame_31.txt | 17 + codex-rs/tui2/frames/openai/frame_32.txt | 17 + codex-rs/tui2/frames/openai/frame_33.txt | 17 + codex-rs/tui2/frames/openai/frame_34.txt | 17 + codex-rs/tui2/frames/openai/frame_35.txt | 17 + codex-rs/tui2/frames/openai/frame_36.txt | 17 + codex-rs/tui2/frames/openai/frame_4.txt | 17 + codex-rs/tui2/frames/openai/frame_5.txt | 17 + codex-rs/tui2/frames/openai/frame_6.txt | 17 + codex-rs/tui2/frames/openai/frame_7.txt | 17 + codex-rs/tui2/frames/openai/frame_8.txt | 17 + codex-rs/tui2/frames/openai/frame_9.txt | 17 + codex-rs/tui2/frames/shapes/frame_1.txt | 17 + codex-rs/tui2/frames/shapes/frame_10.txt | 17 + codex-rs/tui2/frames/shapes/frame_11.txt | 17 + codex-rs/tui2/frames/shapes/frame_12.txt | 17 + codex-rs/tui2/frames/shapes/frame_13.txt | 17 + codex-rs/tui2/frames/shapes/frame_14.txt | 17 + codex-rs/tui2/frames/shapes/frame_15.txt | 17 + codex-rs/tui2/frames/shapes/frame_16.txt | 17 + codex-rs/tui2/frames/shapes/frame_17.txt | 17 + codex-rs/tui2/frames/shapes/frame_18.txt | 17 + codex-rs/tui2/frames/shapes/frame_19.txt | 17 + codex-rs/tui2/frames/shapes/frame_2.txt | 17 + codex-rs/tui2/frames/shapes/frame_20.txt | 17 + codex-rs/tui2/frames/shapes/frame_21.txt | 17 + codex-rs/tui2/frames/shapes/frame_22.txt | 17 + codex-rs/tui2/frames/shapes/frame_23.txt | 17 + codex-rs/tui2/frames/shapes/frame_24.txt | 17 + codex-rs/tui2/frames/shapes/frame_25.txt | 17 + codex-rs/tui2/frames/shapes/frame_26.txt | 17 + codex-rs/tui2/frames/shapes/frame_27.txt | 17 + codex-rs/tui2/frames/shapes/frame_28.txt | 17 + codex-rs/tui2/frames/shapes/frame_29.txt | 17 + codex-rs/tui2/frames/shapes/frame_3.txt | 17 + codex-rs/tui2/frames/shapes/frame_30.txt | 17 + codex-rs/tui2/frames/shapes/frame_31.txt | 17 + codex-rs/tui2/frames/shapes/frame_32.txt | 17 + codex-rs/tui2/frames/shapes/frame_33.txt | 17 + codex-rs/tui2/frames/shapes/frame_34.txt | 17 + codex-rs/tui2/frames/shapes/frame_35.txt | 17 + codex-rs/tui2/frames/shapes/frame_36.txt | 17 + codex-rs/tui2/frames/shapes/frame_4.txt | 17 + codex-rs/tui2/frames/shapes/frame_5.txt | 17 + codex-rs/tui2/frames/shapes/frame_6.txt | 17 + codex-rs/tui2/frames/shapes/frame_7.txt | 17 + codex-rs/tui2/frames/shapes/frame_8.txt | 17 + codex-rs/tui2/frames/shapes/frame_9.txt | 17 + codex-rs/tui2/frames/slug/frame_1.txt | 17 + codex-rs/tui2/frames/slug/frame_10.txt | 17 + codex-rs/tui2/frames/slug/frame_11.txt | 17 + codex-rs/tui2/frames/slug/frame_12.txt | 17 + codex-rs/tui2/frames/slug/frame_13.txt | 17 + codex-rs/tui2/frames/slug/frame_14.txt | 17 + codex-rs/tui2/frames/slug/frame_15.txt | 17 + codex-rs/tui2/frames/slug/frame_16.txt | 17 + codex-rs/tui2/frames/slug/frame_17.txt | 17 + codex-rs/tui2/frames/slug/frame_18.txt | 17 + codex-rs/tui2/frames/slug/frame_19.txt | 17 + codex-rs/tui2/frames/slug/frame_2.txt | 17 + codex-rs/tui2/frames/slug/frame_20.txt | 17 + codex-rs/tui2/frames/slug/frame_21.txt | 17 + codex-rs/tui2/frames/slug/frame_22.txt | 17 + codex-rs/tui2/frames/slug/frame_23.txt | 17 + codex-rs/tui2/frames/slug/frame_24.txt | 17 + codex-rs/tui2/frames/slug/frame_25.txt | 17 + codex-rs/tui2/frames/slug/frame_26.txt | 17 + codex-rs/tui2/frames/slug/frame_27.txt | 17 + codex-rs/tui2/frames/slug/frame_28.txt | 17 + codex-rs/tui2/frames/slug/frame_29.txt | 17 + codex-rs/tui2/frames/slug/frame_3.txt | 17 + codex-rs/tui2/frames/slug/frame_30.txt | 17 + codex-rs/tui2/frames/slug/frame_31.txt | 17 + codex-rs/tui2/frames/slug/frame_32.txt | 17 + codex-rs/tui2/frames/slug/frame_33.txt | 17 + codex-rs/tui2/frames/slug/frame_34.txt | 17 + codex-rs/tui2/frames/slug/frame_35.txt | 17 + codex-rs/tui2/frames/slug/frame_36.txt | 17 + codex-rs/tui2/frames/slug/frame_4.txt | 17 + codex-rs/tui2/frames/slug/frame_5.txt | 17 + codex-rs/tui2/frames/slug/frame_6.txt | 17 + codex-rs/tui2/frames/slug/frame_7.txt | 17 + codex-rs/tui2/frames/slug/frame_8.txt | 17 + codex-rs/tui2/frames/slug/frame_9.txt | 17 + codex-rs/tui2/frames/vbars/frame_1.txt | 17 + codex-rs/tui2/frames/vbars/frame_10.txt | 17 + codex-rs/tui2/frames/vbars/frame_11.txt | 17 + codex-rs/tui2/frames/vbars/frame_12.txt | 17 + codex-rs/tui2/frames/vbars/frame_13.txt | 17 + codex-rs/tui2/frames/vbars/frame_14.txt | 17 + codex-rs/tui2/frames/vbars/frame_15.txt | 17 + codex-rs/tui2/frames/vbars/frame_16.txt | 17 + codex-rs/tui2/frames/vbars/frame_17.txt | 17 + codex-rs/tui2/frames/vbars/frame_18.txt | 17 + codex-rs/tui2/frames/vbars/frame_19.txt | 17 + codex-rs/tui2/frames/vbars/frame_2.txt | 17 + codex-rs/tui2/frames/vbars/frame_20.txt | 17 + codex-rs/tui2/frames/vbars/frame_21.txt | 17 + codex-rs/tui2/frames/vbars/frame_22.txt | 17 + codex-rs/tui2/frames/vbars/frame_23.txt | 17 + codex-rs/tui2/frames/vbars/frame_24.txt | 17 + codex-rs/tui2/frames/vbars/frame_25.txt | 17 + codex-rs/tui2/frames/vbars/frame_26.txt | 17 + codex-rs/tui2/frames/vbars/frame_27.txt | 17 + codex-rs/tui2/frames/vbars/frame_28.txt | 17 + codex-rs/tui2/frames/vbars/frame_29.txt | 17 + codex-rs/tui2/frames/vbars/frame_3.txt | 17 + codex-rs/tui2/frames/vbars/frame_30.txt | 17 + codex-rs/tui2/frames/vbars/frame_31.txt | 17 + codex-rs/tui2/frames/vbars/frame_32.txt | 17 + codex-rs/tui2/frames/vbars/frame_33.txt | 17 + codex-rs/tui2/frames/vbars/frame_34.txt | 17 + codex-rs/tui2/frames/vbars/frame_35.txt | 17 + codex-rs/tui2/frames/vbars/frame_36.txt | 17 + codex-rs/tui2/frames/vbars/frame_4.txt | 17 + codex-rs/tui2/frames/vbars/frame_5.txt | 17 + codex-rs/tui2/frames/vbars/frame_6.txt | 17 + codex-rs/tui2/frames/vbars/frame_7.txt | 17 + codex-rs/tui2/frames/vbars/frame_8.txt | 17 + codex-rs/tui2/frames/vbars/frame_9.txt | 17 + codex-rs/tui2/prompt_for_init_command.md | 40 + codex-rs/tui2/src/additional_dirs.rs | 71 + codex-rs/tui2/src/app.rs | 1510 +++++++ codex-rs/tui2/src/app_backtrack.rs | 518 +++ codex-rs/tui2/src/app_event.rs | 185 + codex-rs/tui2/src/app_event_sender.rs | 28 + codex-rs/tui2/src/ascii_animation.rs | 111 + codex-rs/tui2/src/bin/md-events2.rs | 15 + .../tui2/src/bottom_pane/approval_overlay.rs | 717 +++ .../tui2/src/bottom_pane/bottom_pane_view.rs | 37 + .../tui2/src/bottom_pane/chat_composer.rs | 3990 +++++++++++++++++ .../src/bottom_pane/chat_composer_history.rs | 300 ++ .../tui2/src/bottom_pane/command_popup.rs | 376 ++ .../src/bottom_pane/custom_prompt_view.rs | 247 + .../tui2/src/bottom_pane/feedback_view.rs | 559 +++ .../tui2/src/bottom_pane/file_search_popup.rs | 154 + codex-rs/tui2/src/bottom_pane/footer.rs | 530 +++ .../src/bottom_pane/list_selection_view.rs | 794 ++++ codex-rs/tui2/src/bottom_pane/mod.rs | 814 ++++ codex-rs/tui2/src/bottom_pane/paste_burst.rs | 267 ++ codex-rs/tui2/src/bottom_pane/popup_consts.rs | 21 + codex-rs/tui2/src/bottom_pane/prompt_args.rs | 406 ++ .../src/bottom_pane/queued_user_messages.rs | 157 + codex-rs/tui2/src/bottom_pane/scroll_state.rs | 115 + .../src/bottom_pane/selection_popup_common.rs | 269 ++ codex-rs/tui2/src/bottom_pane/skill_popup.rs | 142 + ...mposer__tests__backspace_after_pastes.snap | 14 + ...tom_pane__chat_composer__tests__empty.snap | 14 + ...__tests__footer_mode_ctrl_c_interrupt.snap | 13 + ...poser__tests__footer_mode_ctrl_c_quit.snap | 13 + ...sts__footer_mode_ctrl_c_then_esc_hint.snap | 13 + ...tests__footer_mode_esc_hint_backtrack.snap | 13 + ...ts__footer_mode_esc_hint_from_overlay.snap | 13 + ...ests__footer_mode_hidden_while_typing.snap | 13 + ...r_mode_overlay_then_external_esc_hint.snap | 13 + ...__tests__footer_mode_shortcut_overlay.snap | 16 + ...tom_pane__chat_composer__tests__large.snap | 14 + ...chat_composer__tests__multiple_pastes.snap | 14 + ..._chat_composer__tests__slash_popup_mo.snap | 9 + ...chat_composer__tests__slash_popup_res.snap | 10 + ...tom_pane__chat_composer__tests__small.snap | 14 + ...view__tests__feedback_view_bad_result.snap | 9 + ...edback_view__tests__feedback_view_bug.snap | 9 + ...iew__tests__feedback_view_good_result.snap | 9 + ...back_view__tests__feedback_view_other.snap | 9 + ...er__tests__footer_context_tokens_used.snap | 5 + ...ooter__tests__footer_ctrl_c_quit_idle.snap | 5 + ...er__tests__footer_ctrl_c_quit_running.snap | 5 + ...__footer__tests__footer_esc_hint_idle.snap | 5 + ...footer__tests__footer_esc_hint_primed.snap | 5 + ...sts__footer_shortcuts_context_running.snap | 5 + ...oter__tests__footer_shortcuts_default.snap | 5 + ...tests__footer_shortcuts_shift_and_esc.snap | 8 + ..._list_selection_model_picker_width_80.snap | 13 + ...selection_narrow_width_preserves_rows.snap | 16 + ..._list_selection_spacing_with_subtitle.snap | 12 + ...st_selection_spacing_without_subtitle.snap | 11 + ...ages__tests__render_many_line_message.snap | 27 + ...ests__render_more_than_three_messages.snap | 30 + ...r_messages__tests__render_one_message.snap | 18 + ..._messages__tests__render_two_messages.snap | 22 + ...ssages__tests__render_wrapped_message.snap | 25 + ...s_visible_when_status_hidden_snapshot.snap | 11 + ...er_fill_height_without_bottom_padding.snap | 10 + ...__status_and_queued_messages_snapshot.snap | 12 + ...mposer__tests__backspace_after_pastes.snap | 14 + ...tom_pane__chat_composer__tests__empty.snap | 14 + ...__tests__footer_mode_ctrl_c_interrupt.snap | 13 + ...poser__tests__footer_mode_ctrl_c_quit.snap | 13 + ...sts__footer_mode_ctrl_c_then_esc_hint.snap | 13 + ...tests__footer_mode_esc_hint_backtrack.snap | 13 + ...ts__footer_mode_esc_hint_from_overlay.snap | 13 + ...ests__footer_mode_hidden_while_typing.snap | 13 + ...r_mode_overlay_then_external_esc_hint.snap | 13 + ...__tests__footer_mode_shortcut_overlay.snap | 16 + ...tom_pane__chat_composer__tests__large.snap | 14 + ...chat_composer__tests__multiple_pastes.snap | 14 + ..._chat_composer__tests__slash_popup_mo.snap | 9 + ...chat_composer__tests__slash_popup_res.snap | 11 + ...tom_pane__chat_composer__tests__small.snap | 14 + ...view__tests__feedback_view_bad_result.snap | 9 + ...edback_view__tests__feedback_view_bug.snap | 9 + ...iew__tests__feedback_view_good_result.snap | 9 + ...back_view__tests__feedback_view_other.snap | 9 + ...ack_view__tests__feedback_view_render.snap | 17 + ...er__tests__footer_context_tokens_used.snap | 5 + ...ooter__tests__footer_ctrl_c_quit_idle.snap | 5 + ...er__tests__footer_ctrl_c_quit_running.snap | 5 + ...__footer__tests__footer_esc_hint_idle.snap | 5 + ...footer__tests__footer_esc_hint_primed.snap | 5 + ...sts__footer_shortcuts_context_running.snap | 5 + ...oter__tests__footer_shortcuts_default.snap | 5 + ...tests__footer_shortcuts_shift_and_esc.snap | 8 + ..._list_selection_model_picker_width_80.snap | 13 + ...selection_narrow_width_preserves_rows.snap | 16 + ..._list_selection_spacing_with_subtitle.snap | 12 + ...st_selection_spacing_without_subtitle.snap | 11 + ...ueue__tests__render_many_line_message.snap | 27 + ...sage_queue__tests__render_one_message.snap | 18 + ...age_queue__tests__render_two_messages.snap | 22 + ..._queue__tests__render_wrapped_message.snap | 25 + ...ages__tests__render_many_line_message.snap | 27 + ...ests__render_more_than_three_messages.snap | 30 + ...r_messages__tests__render_one_message.snap | 18 + ..._messages__tests__render_two_messages.snap | 22 + ...ssages__tests__render_wrapped_message.snap | 25 + ...s_visible_when_status_hidden_snapshot.snap | 11 + ...er_fill_height_without_bottom_padding.snap | 10 + ...__status_and_queued_messages_snapshot.snap | 12 + ...hidden_when_height_too_small_height_1.snap | 5 + codex-rs/tui2/src/bottom_pane/textarea.rs | 2015 +++++++++ codex-rs/tui2/src/chatwidget.rs | 3463 ++++++++++++++ codex-rs/tui2/src/chatwidget/agent.rs | 108 + codex-rs/tui2/src/chatwidget/interrupts.rs | 96 + .../tui2/src/chatwidget/session_header.rs | 16 + ...ly_patch_manual_flow_history_approved.snap | 6 + ...hatwidget__tests__approval_modal_exec.snap | 15 + ..._tests__approval_modal_exec_no_reason.snap | 13 + ...atwidget__tests__approval_modal_patch.snap | 17 + ...get__tests__approvals_selection_popup.snap | 13 + ...ts__approvals_selection_popup@windows.snap | 14 + ...chatwidget__tests__chat_small_idle_h1.snap | 5 + ...chatwidget__tests__chat_small_idle_h2.snap | 6 + ...chatwidget__tests__chat_small_idle_h3.snap | 7 + ...twidget__tests__chat_small_running_h1.snap | 5 + ...twidget__tests__chat_small_running_h2.snap | 6 + ...twidget__tests__chat_small_running_h3.snap | 7 + ...exec_and_status_layout_vt100_snapshot.snap | 17 + ...t_markdown_code_blocks_vt100_snapshot.snap | 18 + ...2__chatwidget__tests__chatwidget_tall.snap | 27 + ...e_final_message_are_rendered_snapshot.snap | 5 + ...h_command_while_task_running_snapshot.snap | 5 + ...pproval_history_decision_aborted_long.snap | 6 + ...al_history_decision_aborted_multiline.snap | 5 + ...roval_history_decision_approved_short.snap | 5 + ...dget__tests__exec_approval_modal_exec.snap | 36 + ...dget__tests__exploring_step1_start_ls.snap | 6 + ...get__tests__exploring_step2_finish_ls.snap | 6 + ..._tests__exploring_step3_start_cat_foo.snap | 7 + ...tests__exploring_step4_finish_cat_foo.snap | 7 + ...sts__exploring_step5_finish_sed_range.snap | 7 + ...tests__exploring_step6_finish_cat_bar.snap | 7 + ...dget__tests__feedback_selection_popup.snap | 11 + ..._tests__feedback_upload_consent_popup.snap | 14 + ...n_message_without_deltas_are_rendered.snap | 5 + ...tests__full_access_confirmation_popup.snap | 15 + ...t__tests__interrupt_exec_marks_failed.snap | 6 + ...tests__interrupted_turn_error_message.snap | 5 + ...cal_image_attachment_history_snapshot.snap | 6 + ...ests__model_reasoning_selection_popup.snap | 12 + ...ng_selection_popup_extra_high_warning.snap | 15 + ...twidget__tests__model_selection_popup.snap | 15 + ...tests__rate_limit_switch_prompt_popup.snap | 14 + ...atwidget__tests__status_widget_active.snap | 11 + ...sts__status_widget_and_approval_modal.snap | 17 + ...atwidget__tests__user_shell_ls_output.snap | 7 + ...ly_patch_manual_flow_history_approved.snap | 6 + ...hatwidget__tests__approval_modal_exec.snap | 17 + ..._tests__approval_modal_exec_no_reason.snap | 13 + ...atwidget__tests__approval_modal_patch.snap | 17 + ...get__tests__approvals_selection_popup.snap | 13 + ...ts__approvals_selection_popup@windows.snap | 13 + ...et__tests__binary_size_ideal_response.snap | 153 + ...chatwidget__tests__chat_small_idle_h1.snap | 5 + ...chatwidget__tests__chat_small_idle_h2.snap | 6 + ...chatwidget__tests__chat_small_idle_h3.snap | 7 + ...twidget__tests__chat_small_running_h1.snap | 5 + ...twidget__tests__chat_small_running_h2.snap | 6 + ...twidget__tests__chat_small_running_h3.snap | 7 + ...exec_and_status_layout_vt100_snapshot.snap | 17 + ...t_markdown_code_blocks_vt100_snapshot.snap | 18 + ...i__chatwidget__tests__chatwidget_tall.snap | 27 + ...e_final_message_are_rendered_snapshot.snap | 5 + ...h_command_while_task_running_snapshot.snap | 5 + ...pproval_history_decision_aborted_long.snap | 7 + ...al_history_decision_aborted_multiline.snap | 6 + ...roval_history_decision_approved_short.snap | 5 + ...dget__tests__exec_approval_modal_exec.snap | 36 + ...dget__tests__exploring_step1_start_ls.snap | 6 + ...get__tests__exploring_step2_finish_ls.snap | 6 + ..._tests__exploring_step3_start_cat_foo.snap | 7 + ...tests__exploring_step4_finish_cat_foo.snap | 7 + ...sts__exploring_step5_finish_sed_range.snap | 7 + ...tests__exploring_step6_finish_cat_bar.snap | 7 + ...dget__tests__feedback_selection_popup.snap | 11 + ..._tests__feedback_upload_consent_popup.snap | 14 + ...n_message_without_deltas_are_rendered.snap | 5 + ...tests__full_access_confirmation_popup.snap | 15 + ...t__tests__interrupt_exec_marks_failed.snap | 6 + ...tests__interrupted_turn_error_message.snap | 5 + ...cal_image_attachment_history_snapshot.snap | 6 + ...ests__model_reasoning_selection_popup.snap | 12 + ...ng_selection_popup_extra_high_warning.snap | 16 + ...twidget__tests__model_selection_popup.snap | 15 + ...tests__rate_limit_switch_prompt_popup.snap | 14 + ...atwidget__tests__status_widget_active.snap | 12 + ...sts__status_widget_and_approval_modal.snap | 17 + ..._tui__chatwidget__tests__update_popup.snap | 14 + ...atwidget__tests__user_shell_ls_output.snap | 7 + codex-rs/tui2/src/chatwidget/tests.rs | 3329 ++++++++++++++ codex-rs/tui2/src/cli.rs | 115 + codex-rs/tui2/src/clipboard_paste.rs | 504 +++ codex-rs/tui2/src/color.rs | 75 + codex-rs/tui2/src/custom_terminal.rs | 645 +++ codex-rs/tui2/src/diff_render.rs | 673 +++ codex-rs/tui2/src/exec_cell/mod.rs | 12 + codex-rs/tui2/src/exec_cell/model.rs | 150 + codex-rs/tui2/src/exec_cell/render.rs | 705 +++ codex-rs/tui2/src/exec_command.rs | 70 + codex-rs/tui2/src/file_search.rs | 199 + codex-rs/tui2/src/frames.rs | 71 + codex-rs/tui2/src/get_git_diff.rs | 119 + codex-rs/tui2/src/history_cell.rs | 2435 ++++++++++ codex-rs/tui2/src/insert_history.rs | 530 +++ codex-rs/tui2/src/key_hint.rs | 112 + codex-rs/tui2/src/lib.rs | 693 ++- codex-rs/tui2/src/live_wrap.rs | 290 ++ codex-rs/tui2/src/main.rs | 3 +- codex-rs/tui2/src/markdown.rs | 105 + codex-rs/tui2/src/markdown_render.rs | 678 +++ codex-rs/tui2/src/markdown_render_tests.rs | 995 ++++ codex-rs/tui2/src/markdown_stream.rs | 670 +++ codex-rs/tui2/src/model_migration.rs | 458 ++ codex-rs/tui2/src/onboarding/auth.rs | 709 +++ codex-rs/tui2/src/onboarding/mod.rs | 5 + .../tui2/src/onboarding/onboarding_screen.rs | 430 ++ ..._tests__renders_snapshot_for_git_repo.snap | 14 + ..._tests__renders_snapshot_for_git_repo.snap | 14 + .../tui2/src/onboarding/trust_directory.rs | 238 + codex-rs/tui2/src/onboarding/welcome.rs | 153 + codex-rs/tui2/src/oss_selection.rs | 369 ++ codex-rs/tui2/src/pager_overlay.rs | 1037 +++++ .../tui2/src/public_widgets/composer_input.rs | 128 + codex-rs/tui2/src/public_widgets/mod.rs | 1 + codex-rs/tui2/src/render/highlight.rs | 236 + codex-rs/tui2/src/render/line_utils.rs | 59 + codex-rs/tui2/src/render/mod.rs | 50 + codex-rs/tui2/src/render/renderable.rs | 431 ++ codex-rs/tui2/src/resume_picker.rs | 1728 +++++++ codex-rs/tui2/src/selection_list.rs | 35 + codex-rs/tui2/src/session_log.rs | 210 + codex-rs/tui2/src/shimmer.rs | 80 + codex-rs/tui2/src/skill_error_prompt.rs | 164 + codex-rs/tui2/src/slash_command.rs | 106 + ...__diff_render__tests__apply_add_block.snap | 14 + ...iff_render__tests__apply_delete_block.snap | 16 + ...er__tests__apply_multiple_files_block.snap | 18 + ...iff_render__tests__apply_update_block.snap | 16 + ..._block_line_numbers_three_digits_text.snap | 13 + ...__apply_update_block_relativizes_path.snap | 14 + ...__apply_update_block_wraps_long_lines.snap | 16 + ...ly_update_block_wraps_long_lines_text.snap | 15 + ...tests__apply_update_with_rename_block.snap | 16 + ...f_render__tests__wrap_behavior_insert.snap | 12 + ..._tests__active_mcp_tool_call_snapshot.snap | 5 + ...__tests__coalesced_reads_dedupe_names.snap | 6 + ...coalesces_reads_across_multiple_calls.snap | 7 + ...sces_sequential_reads_within_one_call.snap | 8 + ...ompleted_mcp_tool_call_error_snapshot.snap | 6 + ...call_multiple_outputs_inline_snapshot.snap | 7 + ...p_tool_call_multiple_outputs_snapshot.snap | 10 + ...pleted_mcp_tool_call_success_snapshot.snap | 6 + ...cp_tool_call_wrapped_outputs_snapshot.snap | 13 + ...p_tools_output_masks_sensitive_values.snap | 26 + ...both_lines_wrap_with_correct_prefixes.snap | 9 + ...ut_wrap_uses_branch_then_eight_spaces.snap | 7 + ...with_extra_indent_on_subsequent_lines.snap | 8 + ...pdate_with_note_and_wrapping_snapshot.snap | 20 + ...ts__plan_update_without_note_snapshot.snap | 7 + ...n_cell_multiline_with_stderr_snapshot.snap | 12 + ...single_line_command_compact_when_fits.snap | 6 + ...nd_wraps_with_four_space_continuation.snap | 8 + ...rr_tail_more_than_five_lines_snapshot.snap | 10 + ...wraps_and_prefixes_each_line_snapshot.snap | 8 + ...sts__markdown_render_complex_snapshot.snap | 62 + ...ration__tests__model_migration_prompt.snap | 19 + ...ts__model_migration_prompt_gpt5_codex.snap | 15 + ...odel_migration_prompt_gpt5_codex_mini.snap | 15 + ...s__model_migration_prompt_gpt5_family.snap | 15 + ..._tests__static_overlay_snapshot_basic.snap | 14 + ...ests__static_overlay_wraps_long_lines.snap | 12 + ...ript_overlay_apply_patch_scroll_vt100.snap | 15 + ...ts__transcript_overlay_snapshot_basic.snap | 14 + ...e_picker__tests__resume_picker_screen.snap | 13 + ...me_picker__tests__resume_picker_table.snap | 8 + ...ator_widget__tests__renders_truncated.snap | 6 + ...t__tests__renders_with_working_header.snap | 6 + ..._tui__diff_render__tests__add_details.snap | 15 + ...__diff_render__tests__apply_add_block.snap | 14 + ...iff_render__tests__apply_delete_block.snap | 16 + ...er__tests__apply_multiple_files_block.snap | 18 + ...iff_render__tests__apply_update_block.snap | 16 + ..._block_line_numbers_three_digits_text.snap | 13 + ...__apply_update_block_relativizes_path.snap | 14 + ...__apply_update_block_wraps_long_lines.snap | 16 + ...ly_update_block_wraps_long_lines_text.snap | 15 + ...tests__apply_update_with_rename_block.snap | 16 + ...iff_render__tests__blank_context_line.snap | 15 + ...tests__single_line_replacement_counts.snap | 13 + ...er__tests__update_details_with_rename.snap | 17 + ...ests__vertical_ellipsis_between_hunks.snap | 21 + ...f_render__tests__wrap_behavior_insert.snap | 12 + ..._tests__active_mcp_tool_call_snapshot.snap | 6 + ...__tests__coalesced_reads_dedupe_names.snap | 6 + ...coalesces_reads_across_multiple_calls.snap | 7 + ...sces_sequential_reads_within_one_call.snap | 8 + ...ompleted_mcp_tool_call_error_snapshot.snap | 7 + ...call_multiple_outputs_inline_snapshot.snap | 8 + ...p_tool_call_multiple_outputs_snapshot.snap | 11 + ...pleted_mcp_tool_call_success_snapshot.snap | 7 + ...cp_tool_call_wrapped_outputs_snapshot.snap | 14 + ...p_tools_output_masks_sensitive_values.snap | 27 + ...both_lines_wrap_with_correct_prefixes.snap | 9 + ...ut_wrap_uses_branch_then_eight_spaces.snap | 7 + ...with_extra_indent_on_subsequent_lines.snap | 8 + ...pdate_with_note_and_wrapping_snapshot.snap | 20 + ...ts__plan_update_without_note_snapshot.snap | 7 + ...n_cell_multiline_with_stderr_snapshot.snap | 12 + ...single_line_command_compact_when_fits.snap | 6 + ...nd_wraps_with_four_space_continuation.snap | 8 + ...rr_tail_more_than_five_lines_snapshot.snap | 10 + ...wraps_and_prefixes_each_line_snapshot.snap | 8 + ...sts__markdown_render_complex_snapshot.snap | 62 + ...ration__tests__model_migration_prompt.snap | 19 + ...ts__model_migration_prompt_gpt5_codex.snap | 15 + ...odel_migration_prompt_gpt5_codex_mini.snap | 15 + ...s__model_migration_prompt_gpt5_family.snap | 15 + ..._tests__static_overlay_snapshot_basic.snap | 14 + ...ests__static_overlay_wraps_long_lines.snap | 13 + ...ript_overlay_apply_patch_scroll_vt100.snap | 15 + ...ts__transcript_overlay_snapshot_basic.snap | 14 + ...e_picker__tests__resume_picker_screen.snap | 14 + ...me_picker__tests__resume_picker_table.snap | 8 + ...ator_widget__tests__renders_truncated.snap | 6 + ...__tests__renders_with_queued_messages.snap | 12 + ...s__renders_with_queued_messages@macos.snap | 13 + ...t__tests__renders_with_working_header.snap | 6 + ...te_prompt__tests__update_prompt_modal.snap | 13 + codex-rs/tui2/src/status/account.rs | 8 + codex-rs/tui2/src/status/card.rs | 409 ++ codex-rs/tui2/src/status/format.rs | 147 + codex-rs/tui2/src/status/helpers.rs | 189 + codex-rs/tui2/src/status/mod.rs | 13 + codex-rs/tui2/src/status/rate_limits.rs | 235 + ...ched_limits_hide_credits_without_flag.snap | 24 + ..._snapshot_includes_credits_and_limits.snap | 24 + ...tatus_snapshot_includes_monthly_limit.snap | 22 + ...s_snapshot_includes_reasoning_details.snap | 23 + ...s_snapshot_shows_empty_limits_message.snap | 22 + ...snapshot_shows_missing_limits_message.snap | 22 + ...s_snapshot_shows_stale_limits_message.snap | 24 + ...snapshot_truncates_in_narrow_terminal.snap | 22 + ...ched_limits_hide_credits_without_flag.snap | 24 + ..._snapshot_includes_credits_and_limits.snap | 24 + ...tatus_snapshot_includes_monthly_limit.snap | 22 + ...s_snapshot_includes_reasoning_details.snap | 23 + ...s_snapshot_shows_empty_limits_message.snap | 22 + ...snapshot_shows_missing_limits_message.snap | 22 + ...s_snapshot_shows_stale_limits_message.snap | 24 + ...snapshot_truncates_in_narrow_terminal.snap | 22 + codex-rs/tui2/src/status/tests.rs | 832 ++++ codex-rs/tui2/src/status_indicator_widget.rs | 253 ++ codex-rs/tui2/src/streaming/controller.rs | 223 + codex-rs/tui2/src/streaming/mod.rs | 39 + codex-rs/tui2/src/style.rs | 28 + codex-rs/tui2/src/terminal_palette.rs | 401 ++ codex-rs/tui2/src/test_backend.rs | 124 + codex-rs/tui2/src/text_formatting.rs | 525 +++ codex-rs/tui2/src/tooltips.rs | 49 + codex-rs/tui2/src/tui.rs | 441 ++ codex-rs/tui2/src/tui/frame_requester.rs | 249 + codex-rs/tui2/src/tui/job_control.rs | 182 + codex-rs/tui2/src/ui_consts.rs | 11 + codex-rs/tui2/src/update_action.rs | 115 + codex-rs/tui2/src/update_prompt.rs | 313 ++ codex-rs/tui2/src/updates.rs | 237 + codex-rs/tui2/src/version.rs | 2 + codex-rs/tui2/src/wrapping.rs | 652 +++ codex-rs/tui2/tooltips.txt | 11 + 742 files changed, 53558 insertions(+), 18 deletions(-) create mode 100644 codex-rs/tui2/frames/blocks/frame_1.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_10.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_11.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_12.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_13.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_14.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_15.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_16.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_17.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_18.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_19.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_2.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_20.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_21.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_22.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_23.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_24.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_25.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_26.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_27.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_28.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_29.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_3.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_30.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_31.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_32.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_33.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_34.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_35.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_36.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_4.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_5.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_6.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_7.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_8.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_9.txt create mode 100644 codex-rs/tui2/frames/codex/frame_1.txt create mode 100644 codex-rs/tui2/frames/codex/frame_10.txt create mode 100644 codex-rs/tui2/frames/codex/frame_11.txt create mode 100644 codex-rs/tui2/frames/codex/frame_12.txt create mode 100644 codex-rs/tui2/frames/codex/frame_13.txt create mode 100644 codex-rs/tui2/frames/codex/frame_14.txt create mode 100644 codex-rs/tui2/frames/codex/frame_15.txt create mode 100644 codex-rs/tui2/frames/codex/frame_16.txt create mode 100644 codex-rs/tui2/frames/codex/frame_17.txt create mode 100644 codex-rs/tui2/frames/codex/frame_18.txt create mode 100644 codex-rs/tui2/frames/codex/frame_19.txt create mode 100644 codex-rs/tui2/frames/codex/frame_2.txt create mode 100644 codex-rs/tui2/frames/codex/frame_20.txt create mode 100644 codex-rs/tui2/frames/codex/frame_21.txt create mode 100644 codex-rs/tui2/frames/codex/frame_22.txt create mode 100644 codex-rs/tui2/frames/codex/frame_23.txt create mode 100644 codex-rs/tui2/frames/codex/frame_24.txt create mode 100644 codex-rs/tui2/frames/codex/frame_25.txt create mode 100644 codex-rs/tui2/frames/codex/frame_26.txt create mode 100644 codex-rs/tui2/frames/codex/frame_27.txt create mode 100644 codex-rs/tui2/frames/codex/frame_28.txt create mode 100644 codex-rs/tui2/frames/codex/frame_29.txt create mode 100644 codex-rs/tui2/frames/codex/frame_3.txt create mode 100644 codex-rs/tui2/frames/codex/frame_30.txt create mode 100644 codex-rs/tui2/frames/codex/frame_31.txt create mode 100644 codex-rs/tui2/frames/codex/frame_32.txt create mode 100644 codex-rs/tui2/frames/codex/frame_33.txt create mode 100644 codex-rs/tui2/frames/codex/frame_34.txt create mode 100644 codex-rs/tui2/frames/codex/frame_35.txt create mode 100644 codex-rs/tui2/frames/codex/frame_36.txt create mode 100644 codex-rs/tui2/frames/codex/frame_4.txt create mode 100644 codex-rs/tui2/frames/codex/frame_5.txt create mode 100644 codex-rs/tui2/frames/codex/frame_6.txt create mode 100644 codex-rs/tui2/frames/codex/frame_7.txt create mode 100644 codex-rs/tui2/frames/codex/frame_8.txt create mode 100644 codex-rs/tui2/frames/codex/frame_9.txt create mode 100644 codex-rs/tui2/frames/default/frame_1.txt create mode 100644 codex-rs/tui2/frames/default/frame_10.txt create mode 100644 codex-rs/tui2/frames/default/frame_11.txt create mode 100644 codex-rs/tui2/frames/default/frame_12.txt create mode 100644 codex-rs/tui2/frames/default/frame_13.txt create mode 100644 codex-rs/tui2/frames/default/frame_14.txt create mode 100644 codex-rs/tui2/frames/default/frame_15.txt create mode 100644 codex-rs/tui2/frames/default/frame_16.txt create mode 100644 codex-rs/tui2/frames/default/frame_17.txt create mode 100644 codex-rs/tui2/frames/default/frame_18.txt create mode 100644 codex-rs/tui2/frames/default/frame_19.txt create mode 100644 codex-rs/tui2/frames/default/frame_2.txt create mode 100644 codex-rs/tui2/frames/default/frame_20.txt create mode 100644 codex-rs/tui2/frames/default/frame_21.txt create mode 100644 codex-rs/tui2/frames/default/frame_22.txt create mode 100644 codex-rs/tui2/frames/default/frame_23.txt create mode 100644 codex-rs/tui2/frames/default/frame_24.txt create mode 100644 codex-rs/tui2/frames/default/frame_25.txt create mode 100644 codex-rs/tui2/frames/default/frame_26.txt create mode 100644 codex-rs/tui2/frames/default/frame_27.txt create mode 100644 codex-rs/tui2/frames/default/frame_28.txt create mode 100644 codex-rs/tui2/frames/default/frame_29.txt create mode 100644 codex-rs/tui2/frames/default/frame_3.txt create mode 100644 codex-rs/tui2/frames/default/frame_30.txt create mode 100644 codex-rs/tui2/frames/default/frame_31.txt create mode 100644 codex-rs/tui2/frames/default/frame_32.txt create mode 100644 codex-rs/tui2/frames/default/frame_33.txt create mode 100644 codex-rs/tui2/frames/default/frame_34.txt create mode 100644 codex-rs/tui2/frames/default/frame_35.txt create mode 100644 codex-rs/tui2/frames/default/frame_36.txt create mode 100644 codex-rs/tui2/frames/default/frame_4.txt create mode 100644 codex-rs/tui2/frames/default/frame_5.txt create mode 100644 codex-rs/tui2/frames/default/frame_6.txt create mode 100644 codex-rs/tui2/frames/default/frame_7.txt create mode 100644 codex-rs/tui2/frames/default/frame_8.txt create mode 100644 codex-rs/tui2/frames/default/frame_9.txt create mode 100644 codex-rs/tui2/frames/dots/frame_1.txt create mode 100644 codex-rs/tui2/frames/dots/frame_10.txt create mode 100644 codex-rs/tui2/frames/dots/frame_11.txt create mode 100644 codex-rs/tui2/frames/dots/frame_12.txt create mode 100644 codex-rs/tui2/frames/dots/frame_13.txt create mode 100644 codex-rs/tui2/frames/dots/frame_14.txt create mode 100644 codex-rs/tui2/frames/dots/frame_15.txt create mode 100644 codex-rs/tui2/frames/dots/frame_16.txt create mode 100644 codex-rs/tui2/frames/dots/frame_17.txt create mode 100644 codex-rs/tui2/frames/dots/frame_18.txt create mode 100644 codex-rs/tui2/frames/dots/frame_19.txt create mode 100644 codex-rs/tui2/frames/dots/frame_2.txt create mode 100644 codex-rs/tui2/frames/dots/frame_20.txt create mode 100644 codex-rs/tui2/frames/dots/frame_21.txt create mode 100644 codex-rs/tui2/frames/dots/frame_22.txt create mode 100644 codex-rs/tui2/frames/dots/frame_23.txt create mode 100644 codex-rs/tui2/frames/dots/frame_24.txt create mode 100644 codex-rs/tui2/frames/dots/frame_25.txt create mode 100644 codex-rs/tui2/frames/dots/frame_26.txt create mode 100644 codex-rs/tui2/frames/dots/frame_27.txt create mode 100644 codex-rs/tui2/frames/dots/frame_28.txt create mode 100644 codex-rs/tui2/frames/dots/frame_29.txt create mode 100644 codex-rs/tui2/frames/dots/frame_3.txt create mode 100644 codex-rs/tui2/frames/dots/frame_30.txt create mode 100644 codex-rs/tui2/frames/dots/frame_31.txt create mode 100644 codex-rs/tui2/frames/dots/frame_32.txt create mode 100644 codex-rs/tui2/frames/dots/frame_33.txt create mode 100644 codex-rs/tui2/frames/dots/frame_34.txt create mode 100644 codex-rs/tui2/frames/dots/frame_35.txt create mode 100644 codex-rs/tui2/frames/dots/frame_36.txt create mode 100644 codex-rs/tui2/frames/dots/frame_4.txt create mode 100644 codex-rs/tui2/frames/dots/frame_5.txt create mode 100644 codex-rs/tui2/frames/dots/frame_6.txt create mode 100644 codex-rs/tui2/frames/dots/frame_7.txt create mode 100644 codex-rs/tui2/frames/dots/frame_8.txt create mode 100644 codex-rs/tui2/frames/dots/frame_9.txt create mode 100644 codex-rs/tui2/frames/hash/frame_1.txt create mode 100644 codex-rs/tui2/frames/hash/frame_10.txt create mode 100644 codex-rs/tui2/frames/hash/frame_11.txt create mode 100644 codex-rs/tui2/frames/hash/frame_12.txt create mode 100644 codex-rs/tui2/frames/hash/frame_13.txt create mode 100644 codex-rs/tui2/frames/hash/frame_14.txt create mode 100644 codex-rs/tui2/frames/hash/frame_15.txt create mode 100644 codex-rs/tui2/frames/hash/frame_16.txt create mode 100644 codex-rs/tui2/frames/hash/frame_17.txt create mode 100644 codex-rs/tui2/frames/hash/frame_18.txt create mode 100644 codex-rs/tui2/frames/hash/frame_19.txt create mode 100644 codex-rs/tui2/frames/hash/frame_2.txt create mode 100644 codex-rs/tui2/frames/hash/frame_20.txt create mode 100644 codex-rs/tui2/frames/hash/frame_21.txt create mode 100644 codex-rs/tui2/frames/hash/frame_22.txt create mode 100644 codex-rs/tui2/frames/hash/frame_23.txt create mode 100644 codex-rs/tui2/frames/hash/frame_24.txt create mode 100644 codex-rs/tui2/frames/hash/frame_25.txt create mode 100644 codex-rs/tui2/frames/hash/frame_26.txt create mode 100644 codex-rs/tui2/frames/hash/frame_27.txt create mode 100644 codex-rs/tui2/frames/hash/frame_28.txt create mode 100644 codex-rs/tui2/frames/hash/frame_29.txt create mode 100644 codex-rs/tui2/frames/hash/frame_3.txt create mode 100644 codex-rs/tui2/frames/hash/frame_30.txt create mode 100644 codex-rs/tui2/frames/hash/frame_31.txt create mode 100644 codex-rs/tui2/frames/hash/frame_32.txt create mode 100644 codex-rs/tui2/frames/hash/frame_33.txt create mode 100644 codex-rs/tui2/frames/hash/frame_34.txt create mode 100644 codex-rs/tui2/frames/hash/frame_35.txt create mode 100644 codex-rs/tui2/frames/hash/frame_36.txt create mode 100644 codex-rs/tui2/frames/hash/frame_4.txt create mode 100644 codex-rs/tui2/frames/hash/frame_5.txt create mode 100644 codex-rs/tui2/frames/hash/frame_6.txt create mode 100644 codex-rs/tui2/frames/hash/frame_7.txt create mode 100644 codex-rs/tui2/frames/hash/frame_8.txt create mode 100644 codex-rs/tui2/frames/hash/frame_9.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_1.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_10.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_11.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_12.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_13.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_14.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_15.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_16.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_17.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_18.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_19.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_2.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_20.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_21.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_22.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_23.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_24.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_25.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_26.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_27.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_28.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_29.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_3.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_30.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_31.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_32.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_33.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_34.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_35.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_36.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_4.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_5.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_6.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_7.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_8.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_9.txt create mode 100644 codex-rs/tui2/frames/openai/frame_1.txt create mode 100644 codex-rs/tui2/frames/openai/frame_10.txt create mode 100644 codex-rs/tui2/frames/openai/frame_11.txt create mode 100644 codex-rs/tui2/frames/openai/frame_12.txt create mode 100644 codex-rs/tui2/frames/openai/frame_13.txt create mode 100644 codex-rs/tui2/frames/openai/frame_14.txt create mode 100644 codex-rs/tui2/frames/openai/frame_15.txt create mode 100644 codex-rs/tui2/frames/openai/frame_16.txt create mode 100644 codex-rs/tui2/frames/openai/frame_17.txt create mode 100644 codex-rs/tui2/frames/openai/frame_18.txt create mode 100644 codex-rs/tui2/frames/openai/frame_19.txt create mode 100644 codex-rs/tui2/frames/openai/frame_2.txt create mode 100644 codex-rs/tui2/frames/openai/frame_20.txt create mode 100644 codex-rs/tui2/frames/openai/frame_21.txt create mode 100644 codex-rs/tui2/frames/openai/frame_22.txt create mode 100644 codex-rs/tui2/frames/openai/frame_23.txt create mode 100644 codex-rs/tui2/frames/openai/frame_24.txt create mode 100644 codex-rs/tui2/frames/openai/frame_25.txt create mode 100644 codex-rs/tui2/frames/openai/frame_26.txt create mode 100644 codex-rs/tui2/frames/openai/frame_27.txt create mode 100644 codex-rs/tui2/frames/openai/frame_28.txt create mode 100644 codex-rs/tui2/frames/openai/frame_29.txt create mode 100644 codex-rs/tui2/frames/openai/frame_3.txt create mode 100644 codex-rs/tui2/frames/openai/frame_30.txt create mode 100644 codex-rs/tui2/frames/openai/frame_31.txt create mode 100644 codex-rs/tui2/frames/openai/frame_32.txt create mode 100644 codex-rs/tui2/frames/openai/frame_33.txt create mode 100644 codex-rs/tui2/frames/openai/frame_34.txt create mode 100644 codex-rs/tui2/frames/openai/frame_35.txt create mode 100644 codex-rs/tui2/frames/openai/frame_36.txt create mode 100644 codex-rs/tui2/frames/openai/frame_4.txt create mode 100644 codex-rs/tui2/frames/openai/frame_5.txt create mode 100644 codex-rs/tui2/frames/openai/frame_6.txt create mode 100644 codex-rs/tui2/frames/openai/frame_7.txt create mode 100644 codex-rs/tui2/frames/openai/frame_8.txt create mode 100644 codex-rs/tui2/frames/openai/frame_9.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_1.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_10.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_11.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_12.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_13.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_14.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_15.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_16.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_17.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_18.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_19.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_2.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_20.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_21.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_22.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_23.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_24.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_25.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_26.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_27.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_28.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_29.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_3.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_30.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_31.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_32.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_33.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_34.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_35.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_36.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_4.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_5.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_6.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_7.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_8.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_9.txt create mode 100644 codex-rs/tui2/frames/slug/frame_1.txt create mode 100644 codex-rs/tui2/frames/slug/frame_10.txt create mode 100644 codex-rs/tui2/frames/slug/frame_11.txt create mode 100644 codex-rs/tui2/frames/slug/frame_12.txt create mode 100644 codex-rs/tui2/frames/slug/frame_13.txt create mode 100644 codex-rs/tui2/frames/slug/frame_14.txt create mode 100644 codex-rs/tui2/frames/slug/frame_15.txt create mode 100644 codex-rs/tui2/frames/slug/frame_16.txt create mode 100644 codex-rs/tui2/frames/slug/frame_17.txt create mode 100644 codex-rs/tui2/frames/slug/frame_18.txt create mode 100644 codex-rs/tui2/frames/slug/frame_19.txt create mode 100644 codex-rs/tui2/frames/slug/frame_2.txt create mode 100644 codex-rs/tui2/frames/slug/frame_20.txt create mode 100644 codex-rs/tui2/frames/slug/frame_21.txt create mode 100644 codex-rs/tui2/frames/slug/frame_22.txt create mode 100644 codex-rs/tui2/frames/slug/frame_23.txt create mode 100644 codex-rs/tui2/frames/slug/frame_24.txt create mode 100644 codex-rs/tui2/frames/slug/frame_25.txt create mode 100644 codex-rs/tui2/frames/slug/frame_26.txt create mode 100644 codex-rs/tui2/frames/slug/frame_27.txt create mode 100644 codex-rs/tui2/frames/slug/frame_28.txt create mode 100644 codex-rs/tui2/frames/slug/frame_29.txt create mode 100644 codex-rs/tui2/frames/slug/frame_3.txt create mode 100644 codex-rs/tui2/frames/slug/frame_30.txt create mode 100644 codex-rs/tui2/frames/slug/frame_31.txt create mode 100644 codex-rs/tui2/frames/slug/frame_32.txt create mode 100644 codex-rs/tui2/frames/slug/frame_33.txt create mode 100644 codex-rs/tui2/frames/slug/frame_34.txt create mode 100644 codex-rs/tui2/frames/slug/frame_35.txt create mode 100644 codex-rs/tui2/frames/slug/frame_36.txt create mode 100644 codex-rs/tui2/frames/slug/frame_4.txt create mode 100644 codex-rs/tui2/frames/slug/frame_5.txt create mode 100644 codex-rs/tui2/frames/slug/frame_6.txt create mode 100644 codex-rs/tui2/frames/slug/frame_7.txt create mode 100644 codex-rs/tui2/frames/slug/frame_8.txt create mode 100644 codex-rs/tui2/frames/slug/frame_9.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_1.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_10.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_11.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_12.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_13.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_14.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_15.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_16.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_17.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_18.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_19.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_2.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_20.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_21.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_22.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_23.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_24.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_25.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_26.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_27.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_28.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_29.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_3.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_30.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_31.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_32.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_33.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_34.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_35.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_36.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_4.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_5.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_6.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_7.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_8.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_9.txt create mode 100644 codex-rs/tui2/prompt_for_init_command.md create mode 100644 codex-rs/tui2/src/additional_dirs.rs create mode 100644 codex-rs/tui2/src/app.rs create mode 100644 codex-rs/tui2/src/app_backtrack.rs create mode 100644 codex-rs/tui2/src/app_event.rs create mode 100644 codex-rs/tui2/src/app_event_sender.rs create mode 100644 codex-rs/tui2/src/ascii_animation.rs create mode 100644 codex-rs/tui2/src/bin/md-events2.rs create mode 100644 codex-rs/tui2/src/bottom_pane/approval_overlay.rs create mode 100644 codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs create mode 100644 codex-rs/tui2/src/bottom_pane/chat_composer.rs create mode 100644 codex-rs/tui2/src/bottom_pane/chat_composer_history.rs create mode 100644 codex-rs/tui2/src/bottom_pane/command_popup.rs create mode 100644 codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs create mode 100644 codex-rs/tui2/src/bottom_pane/feedback_view.rs create mode 100644 codex-rs/tui2/src/bottom_pane/file_search_popup.rs create mode 100644 codex-rs/tui2/src/bottom_pane/footer.rs create mode 100644 codex-rs/tui2/src/bottom_pane/list_selection_view.rs create mode 100644 codex-rs/tui2/src/bottom_pane/mod.rs create mode 100644 codex-rs/tui2/src/bottom_pane/paste_burst.rs create mode 100644 codex-rs/tui2/src/bottom_pane/popup_consts.rs create mode 100644 codex-rs/tui2/src/bottom_pane/prompt_args.rs create mode 100644 codex-rs/tui2/src/bottom_pane/queued_user_messages.rs create mode 100644 codex-rs/tui2/src/bottom_pane/scroll_state.rs create mode 100644 codex-rs/tui2/src/bottom_pane/selection_popup_common.rs create mode 100644 codex-rs/tui2/src/bottom_pane/skill_popup.rs create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap create mode 100644 codex-rs/tui2/src/bottom_pane/textarea.rs create mode 100644 codex-rs/tui2/src/chatwidget.rs create mode 100644 codex-rs/tui2/src/chatwidget/agent.rs create mode 100644 codex-rs/tui2/src/chatwidget/interrupts.rs create mode 100644 codex-rs/tui2/src/chatwidget/session_header.rs create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap create mode 100644 codex-rs/tui2/src/chatwidget/tests.rs create mode 100644 codex-rs/tui2/src/cli.rs create mode 100644 codex-rs/tui2/src/clipboard_paste.rs create mode 100644 codex-rs/tui2/src/color.rs create mode 100644 codex-rs/tui2/src/custom_terminal.rs create mode 100644 codex-rs/tui2/src/diff_render.rs create mode 100644 codex-rs/tui2/src/exec_cell/mod.rs create mode 100644 codex-rs/tui2/src/exec_cell/model.rs create mode 100644 codex-rs/tui2/src/exec_cell/render.rs create mode 100644 codex-rs/tui2/src/exec_command.rs create mode 100644 codex-rs/tui2/src/file_search.rs create mode 100644 codex-rs/tui2/src/frames.rs create mode 100644 codex-rs/tui2/src/get_git_diff.rs create mode 100644 codex-rs/tui2/src/history_cell.rs create mode 100644 codex-rs/tui2/src/insert_history.rs create mode 100644 codex-rs/tui2/src/key_hint.rs create mode 100644 codex-rs/tui2/src/live_wrap.rs create mode 100644 codex-rs/tui2/src/markdown.rs create mode 100644 codex-rs/tui2/src/markdown_render.rs create mode 100644 codex-rs/tui2/src/markdown_render_tests.rs create mode 100644 codex-rs/tui2/src/markdown_stream.rs create mode 100644 codex-rs/tui2/src/model_migration.rs create mode 100644 codex-rs/tui2/src/onboarding/auth.rs create mode 100644 codex-rs/tui2/src/onboarding/mod.rs create mode 100644 codex-rs/tui2/src/onboarding/onboarding_screen.rs create mode 100644 codex-rs/tui2/src/onboarding/snapshots/codex_tui2__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap create mode 100644 codex-rs/tui2/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap create mode 100644 codex-rs/tui2/src/onboarding/trust_directory.rs create mode 100644 codex-rs/tui2/src/onboarding/welcome.rs create mode 100644 codex-rs/tui2/src/oss_selection.rs create mode 100644 codex-rs/tui2/src/pager_overlay.rs create mode 100644 codex-rs/tui2/src/public_widgets/composer_input.rs create mode 100644 codex-rs/tui2/src/public_widgets/mod.rs create mode 100644 codex-rs/tui2/src/render/highlight.rs create mode 100644 codex-rs/tui2/src/render/line_utils.rs create mode 100644 codex-rs/tui2/src/render/mod.rs create mode 100644 codex-rs/tui2/src/render/renderable.rs create mode 100644 codex-rs/tui2/src/resume_picker.rs create mode 100644 codex-rs/tui2/src/selection_list.rs create mode 100644 codex-rs/tui2/src/session_log.rs create mode 100644 codex-rs/tui2/src/shimmer.rs create mode 100644 codex-rs/tui2/src/skill_error_prompt.rs create mode 100644 codex-rs/tui2/src/slash_command.rs create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_add_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_delete_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_multiple_files_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block_line_numbers_three_digits_text.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block_relativizes_path.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block_wraps_long_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block_wraps_long_lines_text.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_with_rename_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__wrap_behavior_insert.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__active_mcp_tool_call_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__coalesced_reads_dedupe_names.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__coalesces_reads_across_multiple_calls.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__coalesces_sequential_reads_within_one_call.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__plan_update_without_note_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__single_line_command_compact_when_fits.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__model_migration__tests__model_migration_prompt.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__model_migration__tests__model_migration_prompt_gpt5_codex.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__model_migration__tests__model_migration_prompt_gpt5_family.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__static_overlay_snapshot_basic.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__static_overlay_wraps_long_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_snapshot_basic.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_table.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__status_indicator_widget__tests__renders_truncated.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__status_indicator_widget__tests__renders_with_working_header.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__add_details.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_add_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_delete_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_multiple_files_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block_line_numbers_three_digits_text.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block_relativizes_path.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines_text.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_with_rename_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages@macos.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap create mode 100644 codex-rs/tui2/src/status/account.rs create mode 100644 codex-rs/tui2/src/status/card.rs create mode 100644 codex-rs/tui2/src/status/format.rs create mode 100644 codex-rs/tui2/src/status/helpers.rs create mode 100644 codex-rs/tui2/src/status/mod.rs create mode 100644 codex-rs/tui2/src/status/rate_limits.rs create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_credits_and_limits.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_monthly_limit.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_reasoning_details.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_empty_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_missing_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_stale_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_truncates_in_narrow_terminal.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap create mode 100644 codex-rs/tui2/src/status/tests.rs create mode 100644 codex-rs/tui2/src/status_indicator_widget.rs create mode 100644 codex-rs/tui2/src/streaming/controller.rs create mode 100644 codex-rs/tui2/src/streaming/mod.rs create mode 100644 codex-rs/tui2/src/style.rs create mode 100644 codex-rs/tui2/src/terminal_palette.rs create mode 100644 codex-rs/tui2/src/test_backend.rs create mode 100644 codex-rs/tui2/src/text_formatting.rs create mode 100644 codex-rs/tui2/src/tooltips.rs create mode 100644 codex-rs/tui2/src/tui.rs create mode 100644 codex-rs/tui2/src/tui/frame_requester.rs create mode 100644 codex-rs/tui2/src/tui/job_control.rs create mode 100644 codex-rs/tui2/src/ui_consts.rs create mode 100644 codex-rs/tui2/src/update_action.rs create mode 100644 codex-rs/tui2/src/update_prompt.rs create mode 100644 codex-rs/tui2/src/updates.rs create mode 100644 codex-rs/tui2/src/version.rs create mode 100644 codex-rs/tui2/src/wrapping.rs create mode 100644 codex-rs/tui2/tooltips.txt diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 304343bf2c3..95f4fecc48e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1596,11 +1596,68 @@ name = "codex-tui2" version = "0.0.0" dependencies = [ "anyhow", + "arboard", + "assert_matches", + "async-stream", + "base64", + "chrono", "clap", + "codex-ansi-escape", + "codex-app-server-protocol", "codex-arg0", + "codex-backend-client", "codex-common", "codex-core", + "codex-feedback", + "codex-file-search", + "codex-login", + "codex-protocol", "codex-tui", + "codex-windows-sandbox", + "color-eyre", + "crossterm", + "derive_more 2.1.0", + "diffy", + "dirs", + "dunce", + "image", + "insta", + "itertools 0.14.0", + "lazy_static", + "libc", + "mcp-types", + "opentelemetry-appender-tracing", + "pathdiff", + "pretty_assertions", + "pulldown-cmark", + "rand 0.9.2", + "ratatui", + "ratatui-macros", + "regex-lite", + "reqwest", + "serde", + "serde_json", + "serial_test", + "shlex", + "strum 0.27.2", + "strum_macros 0.27.2", + "supports-color 3.0.2", + "tempfile", + "textwrap 0.16.2", + "tokio", + "tokio-stream", + "tokio-util", + "toml", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tree-sitter-bash", + "tree-sitter-highlight", + "unicode-segmentation", + "unicode-width 0.2.1", + "url", + "uuid", + "vt100", ] [[package]] diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index c3788f83f44..113c6a75153 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -663,7 +663,8 @@ async fn run_interactive_tui( codex_linux_sandbox_exe: Option, ) -> std::io::Result { if is_tui2_enabled(&interactive).await? { - tui2::run_main(interactive, codex_linux_sandbox_exe).await + let result = tui2::run_main(interactive.into(), codex_linux_sandbox_exe).await?; + Ok(result.into()) } else { codex_tui::run_main(interactive, codex_linux_sandbox_exe).await } diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml index fececb15036..06308e996c9 100644 --- a/codex-rs/tui2/Cargo.toml +++ b/codex-rs/tui2/Cargo.toml @@ -13,7 +13,7 @@ name = "codex-tui2" path = "src/main.rs" [features] -# Keep feature surface aligned with codex-tui while tui2 delegates to it. +# Keep feature surface aligned with codex-tui while tui2 evolves separately. vt100-tests = [] debug-logs = [] @@ -22,8 +22,95 @@ workspace = true [dependencies] 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-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } -codex-common = { workspace = true } +codex-backend-client = { workspace = true } +codex-common = { workspace = true, features = [ + "cli", + "elapsed", + "sandbox_summary", +] } codex-core = { workspace = true } +codex-feedback = { workspace = true } +codex-file-search = { workspace = true } +codex-login = { workspace = true } +codex-protocol = { workspace = true } codex-tui = { workspace = true } +color-eyre = { workspace = true } +crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } +derive_more = { workspace = true, features = ["is_variant"] } +diffy = { workspace = true } +dirs = { workspace = true } +dunce = { workspace = true } +image = { workspace = true, features = ["jpeg", "png"] } +itertools = { workspace = true } +lazy_static = { workspace = true } +mcp-types = { workspace = true } +opentelemetry-appender-tracing = { workspace = true } +pathdiff = { workspace = true } +pulldown-cmark = { workspace = true } +rand = { workspace = true } +ratatui = { workspace = true, features = [ + "scrolling-regions", + "unstable-backend-writer", + "unstable-rendered-line-info", + "unstable-widget-ref", +] } +ratatui-macros = { workspace = true } +regex-lite = { workspace = true } +reqwest = { version = "0.12", features = ["json"] } +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", + "test-util", + "time", +] } +tokio-stream = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true, features = ["log"] } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +tree-sitter-bash = { workspace = true } +tree-sitter-highlight = { workspace = true } +unicode-segmentation = { workspace = true } +unicode-width = { workspace = true } +url = { workspace = true } + +codex-windows-sandbox = { workspace = true } +tokio-util = { workspace = true, features = ["time"] } + +[target.'cfg(unix)'.dependencies] +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 = { workspace = true } + + +[dev-dependencies] +codex-core = { workspace = true, features = ["test-support"] } +assert_matches = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +insta = { workspace = true } +pretty_assertions = { workspace = true } +rand = { workspace = true } +serial_test = { workspace = true } +vt100 = { workspace = true } +uuid = { workspace = true } diff --git a/codex-rs/tui2/frames/blocks/frame_1.txt b/codex-rs/tui2/frames/blocks/frame_1.txt new file mode 100644 index 00000000000..8c3263f5184 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_1.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▒██▒▒██▒ + ▒▒█▓█▒█▓█▒▒░░▒▒ ▒ █▒ + █░█░███ ▒░ ░ █░ ░▒░░░█ + ▓█▒▒████▒ ▓█░▓░█ + ▒▒▓▓█▒░▒░▒▒ ▓░▒▒█ + ░█ █░ ░█▓▓░░█ █▓▒░░█ + █▒ ▓█ █▒░█▓ ░▒ ░▓░ + ░░▒░░ █▓▓░▓░█ ░░ + ░▒░█░ ▓░░▒▒░ ▓░██████▒██ ▒ ░ + ▒░▓█ ▒▓█░ ▓█ ░ ░▒▒▒▓▓███░▓█▓█░ + ▒▒▒ ▒ ▒▒█▓▓░ ░▒████ ▒█ ▓█▓▒▓ + █▒█ █ ░ ██▓█▒░ + ▒▒█░▒█▒ ▒▒▒█░▒█ + ▒██▒▒ ██▓▓▒▓▓▓▒██▒█░█ + ░█ █░░░▒▒▒█▒▓██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_10.txt b/codex-rs/tui2/frames/blocks/frame_10.txt new file mode 100644 index 00000000000..a6fbbf1a4b8 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_10.txt @@ -0,0 +1,17 @@ + + ▒████▒██▒ + ██░███▒░▓▒██ + ▒▒█░░▓░░▓░█▒██ + ░▒▒▓▒░▓▒▓▒███▒▒█ + ▓ ▓░░ ░▒ ██▓▒▓░▓ + ░░ █░█░▓▓▒ ░▒ ░ + ▒ ░█ █░░░░█ ░▓█ + ░░▒█▓█░░▓▒░▓▒░░ + ░▒ ▒▒░▓░░█▒█▓░░ + ░ █░▒█░▒▓▒█▒▒▒░█░ + █ ░░░░░ ▒█ ▒░░ + ▒░██▒██ ▒░ █▓▓ + ░█ ░░░░██▓█▓░▓░ + ▓░██▓░█▓▒ ▓▓█ + ██ ▒█▒▒█▓█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_11.txt b/codex-rs/tui2/frames/blocks/frame_11.txt new file mode 100644 index 00000000000..88e3dfa7c58 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_11.txt @@ -0,0 +1,17 @@ + + ███████▒ + ▓ ▓░░░▒▒█ + ▓ ▒▒░░▓▒█▓▒█ + ░▒▒░░▒▓█▒▒▓▓ + ▒ ▓▓▒░█▒█▓▒░░█ + ░█░░░█▒▓▓░▒▓░░ + ██ █░░░░░░▒░▒▒ + ░ ░░▓░░▒▓ ░ ░ + ▓ █░▓░░█▓█░▒░ + ██ ▒░▓▒█ ▓░▒░▒ + █░▓ ░░░░▒▓░▒▒░ + ▒▒▓▓░▒█▓██▓░░ + ▒ █░▒▒▒▒░▓ + ▒█ █░░█▒▓█░ + ▒▒ ███▒█░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_12.txt b/codex-rs/tui2/frames/blocks/frame_12.txt new file mode 100644 index 00000000000..c6c0ef3e87d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_12.txt @@ -0,0 +1,17 @@ + + █████▓ + █▒░▒▓░█▒ + ░▓▒██ + ▓█░░░▒▒ ░ + ░ █░░░░▓▓░ + ░█▓▓█▒ ▒░ + ░ ░▓▒░░▒ + ░ ▓█▒░░ + ██ ░▓░░█░░ + ░ ▓░█▓█▒ + ░▓ ░ ▒██▓ + █ █░ ▒█░ + ▓ ██░██▒░ + █▒▓ █░▒░░ + ▒ █░▒▓▓ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_13.txt b/codex-rs/tui2/frames/blocks/frame_13.txt new file mode 100644 index 00000000000..7a090e51e33 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_13.txt @@ -0,0 +1,17 @@ + + ▓████ + ░▒▒░░ + ░░▒░ + ░██░▒ + █ ░░ + ▓▓░░ + █ ░░ + █ ░ + ▓█ ▒░▓ + ░ █▒░ + █░▓▓ ░░ + ░▒▒▒░ + ░██░▒ + █▒▒░▒ + █ ▓ ▒ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_14.txt b/codex-rs/tui2/frames/blocks/frame_14.txt new file mode 100644 index 00000000000..f5e74d12b7e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_14.txt @@ -0,0 +1,17 @@ + + ████▓ + █▓▒▒▓▒ + ░▒░░▓ ░ + ░░▓░ ▒░█ + ░░░▒ ░ + ░█░░ █░ + ░░░░ ▓ █ + ░░▒░░ ▒ + ░░░░ + ▒▓▓ ▓▓ + ▒░ █▓█░ + ░█░░▒▒▒░ + ▓ ░▒▒▒░ + ░▒▓█▒▒▓ + ▒█ █▒▓ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_15.txt b/codex-rs/tui2/frames/blocks/frame_15.txt new file mode 100644 index 00000000000..f04599ea27d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_15.txt @@ -0,0 +1,17 @@ + + █████░▒ + ░█▒░░▒▓██ + ▓▓░█▒▒░ █░ + ░▓░ ▓▓█▓▒▒░ + ░░▒ ▒▒░░▓ ▒░ + ▒░░▓░░▓▓░ + ░░ ░░░░░░█░ + ░░▓░░█░░░ █▓░ + ░░████░░░▒▓▓░ + ░▒░▓▓░▒░█▓ ▓░ + ░▓░░░░▒░ ░ ▓ + ░██▓▒░░▒▓ ▒ + █░▒█ ▓▓▓░ ▓░ + ░▒░░▒▒▓█▒▓ + ▒▒█▒▒▒▒▓ + ░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_16.txt b/codex-rs/tui2/frames/blocks/frame_16.txt new file mode 100644 index 00000000000..1eb080286ec --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_16.txt @@ -0,0 +1,17 @@ + + ▒▒█ ███░▒ + ▓▒░░█░░▒░▒▒ + ░▓▓ ▒▓▒▒░░ █▒ + ▓▓▓ ▓█▒▒░▒░░██░ + ░░▓▒▓██▒░░█▓░░▒ + ░░░█░█ ░▒▒ ░ ░▓░ + ▒▒░ ▓░█░░░░▓█ █ ░ + ░▓▓ ░░░░▓░░░ ▓ ░░ + ▒▒░░░█░▓▒░░ ██ ▓ + █ ▒▒█▒▒▒█░▓▒░ █▒░ + ░░░█ ▓█▒░▓ ▓▓░░░ + ░░█ ░░ ░▓▓█ ▓ + ▒░█ ░ ▓█▓▒█░ + ▒░░ ▒█░▓▓█▒░ + █▓▓▒▒▓▒▒▓█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_17.txt b/codex-rs/tui2/frames/blocks/frame_17.txt new file mode 100644 index 00000000000..dd5f5c8da5f --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_17.txt @@ -0,0 +1,17 @@ + + █▒███▓▓░█▒ + ▒▓██░░░█▒█░█ ▒█ + ██▒▓▒▒▒░██ ░░░▒ ▒ + ▓░▓▒▓░ ▒░ █░▓▒░░░▒▒ + ░▓▒ ░ ░ ▓▒▒▒▓▓ █ + ░▒██▓░ █▓▓░ ▓█▒▓░▓▓ + █ ▓▓░ █▓▓░▒ █ ░░▓▒░ + ▓ ▒░ ▓▓░░▓░█░░▒▓█ + █▓█▓▒▒▒█░▒▒░▒▒▓▒░░░ ░ + ░ ▒▓▒▒░▓█▒▓░░▒ ▒███▒ + ▒▒▒▓ ████▒▒░█▓▓▒ ▒█ + ▒░░▒█ ░▓░░░ ▓ + ▒▒▒ █▒▒ ███▓▒▒▓ + █ ░██▒▒█░▒▓█▓░█ + ░█▓▓▒██░█▒██ + ░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_18.txt b/codex-rs/tui2/frames/blocks/frame_18.txt new file mode 100644 index 00000000000..a6c93e6c01d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_18.txt @@ -0,0 +1,17 @@ + + ▒▒▒█▒▒█▓░█▒ + ▒█ ▒▓███░▒▒█ █▓▓▒ + ▒▓▓░█ █▒ █ ▓▒ █▓▓▒ █ + █░░█▓█▒ █ █▒░▒▓▒░▒▓▒▒▒█ + ▒▒▓▓ ▓░ ▒ █▒▒▓░▓░▒▒▓▒▒▒ + ▓▒░ ██░▓▒▒▒▓███░█▓▓▒▓░▓░ + ░░▒▓▓ █▓█▓░ ▒▓ █░▒░▒█ + ▒▓░░ ▒▒ ░░▓▒ ░▓░ + ▒ █▒▒▒▓▒▓█░░█░█▓▒█ ░█░░ + ▒▒▒░█▒█ ░░▓▒▒▒▒░░░▒▓░░▒ █ + ░▓░▒░ █████░ ▒▒▒▓░▓█▓░▓░ + ▒▒ █▒█ ░░█ ▓█▒█ + ▒▒██▒▒▓ ▒█▒▒▓▒█░ + █░▓████▒▒▒▒██▒▓▒██ + ░░▒▓▒▒█▓█ ▓█ + ░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_19.txt b/codex-rs/tui2/frames/blocks/frame_19.txt new file mode 100644 index 00000000000..73341b5d581 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_19.txt @@ -0,0 +1,17 @@ + + ▒▒▒▒█░█▒▒░▓▒ + ▒█░░░▒▓▒▒▒▒█▒█░███ + ██▓▓▓ ░██░ ░█▓█░█▓▒ + ▓▓░██▒░ ▒▒▒██▒░██ + ░░▓░▓░ █░▒ ▓ ░▒ ░▒█ + ░▒▓██ ▒░█░▓ ▓▓ █▓█░ + ▒▒░░█ ▓█▒▓░██░ ▓▓▓█░ + ░░░░ ░▓ ▒░ █ ░ ░░░ + ░█░▒█▒▓▓▒▒▒░░░░██▓█░▓ ▒ ░░ + ▒▓▓█░▒█▓▒██▒█░█ ▒▒ ▓▒▒▒█▓▓░▒ + █▒ ▓█░ ██ ▒▒▒▓░▓▓ ▓▓█ + ▒▒▒█▒▒ ░▓▓▒▓▓█ + █ ▒▒░░██ █▓▒▓▓░▓░ + █ ▓░█▓░█▒▒▒▓▓█ ▓█░█ + ░▓▒▓▓█▒█▓▒█▓▒ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_2.txt b/codex-rs/tui2/frames/blocks/frame_2.txt new file mode 100644 index 00000000000..1c7578c970e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_2.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▒█▒▒▒██▒ + ▒██▓█▓█░░░▒░░▒▒█░██▒ + █░█░▒██░█░░ ░ █▒█▓░░▓░█ + ▒░▓▒▓████▒ ▓█▒░▓░█ + █▒ ▓█▒░▒▒▒▒▒ ▒█░▒░█ + █▓█ ░ ░█▒█▓▒█ ▒▒░█░ + █░██░ ▒▓░▓░▒░█ ▓ ░ ░ + ░ ▒░ █░█░░▓█ ░█▓▓░ + █ ▒░ ▓░▒▒▒░ ▓░█████████░▒░░█ + ▒▒█░ ▓░░█ ▓█ ░▒▒▒▒▒▒▓▓▒▒░█▓ ░ + ▒▒▒ █ █▒▓▓░█ ░ ███████ ░██░░ + █▒▒▓▓█ ░ ██▓▓██ + ▓▒▒▒░██ █▒▒█ ▒░ + ░░▒▓▒▒ ██▓▓▒▓▓▓▒█░▒░░█ + ░████░░▒▒▒▒░▓▓█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_20.txt b/codex-rs/tui2/frames/blocks/frame_20.txt new file mode 100644 index 00000000000..3e0c5f0d9ce --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_20.txt @@ -0,0 +1,17 @@ + + ▒▒█▒░░▒█▒█▒▒ + █▓▒ ▓█▒█▒▒▒░░▒▒█▒██ + ██ ▒██ ░█ ░ ▒ ▒██░█▒ + ▒░ ▒█░█ ▒██░▒▓█▒▒ + ▒░ █░█ ▒▓ ▒░░▒█▒░░▒ + ▓░█░█ ███▓░ ▓ █▒░░▒ + ▓░▓█░ ██ ▓██▒ █▒░▓ + ░▒▒▓░ ▓▓░ █ ░░ ░ + ░▓░░▓█▒▓▒▒▒▒▒▒▒██▓▒▒▒▒█ ▓ ░▒ + █░▒░▒ ▓░░▒▒▒▒░▒ █▒▒ ░▒▒ █▓ ░░ + ▒█▒▒█ █ ▒█▒░░█░ ▓▒ + █ ▒█▓█ ▒▓█▓░▓ + ▒▒▒██░▒ █▓█░▓██ + ▒█▓▓ ░█▒▓▓█▓ ░ ░█▓██ + ░██░▒ ▒▒▒▒▒░█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_21.txt b/codex-rs/tui2/frames/blocks/frame_21.txt new file mode 100644 index 00000000000..971877651f3 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_21.txt @@ -0,0 +1,17 @@ + + ▒▒█▒█▒▒█▒██▒▒ + ███░░▒▒█░▒░█▓▒░▓██▒ + ▓█▒▒██▒ ░ ░▒░██▒░██ + ██░▓ █ ▒█▓██▓██ + ▓█▓█░ █░▓▒▒ ▒▒▒▒█ + ▓ ▓░ ███▒▓▓ ▒▒▒█ + ░█░░ ▒ ▓░█▓█ ▒▓▒ + ░▒ ▒▓ ░█ ░ ░ + ░ ░ ██▓▓▓▓▓███ ▒░█ ░█ ▓▓ ░ + ░ ░▒ ░▒ ▒█░ ▒ ░█░█ ▓ ▓▓ + ▓ ▓ ░░ █░ ██▒█▓ ▓░ █ + ██ ▓▓▒ ▒█ ▓ + █▒ ▒▓▒ ▒▓▓██ █░ + █▒▒ █ ██▓░░▓▓▒█ ▓░ + ███▓█▒▒▒▒█▒▓██░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_22.txt b/codex-rs/tui2/frames/blocks/frame_22.txt new file mode 100644 index 00000000000..2713fd669e2 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_22.txt @@ -0,0 +1,17 @@ + + ▒██▒█▒▒█▒██▒ + ▒█▓█░▓▒▓░▓▒░░▓░█▓██▒ + █▓█▓░▒██░ ░ █▒███▒▒██ + ▓█░██ ██░░░▒█▒ + ▒░░▓█ █▒▓░▒░▓▓▓█░ + ▒░█▓░ █░▓░▓▒▓░ ▒░▒▒░ + ░██▒▓ ░█░▒█▓█ ░░▓░ + ░░▒░░ ░▒░░▒▒ ░▒░ ░ + ░░█ █ █░▒▒▓▓▓▒██▒▒█░▒ ▒█ ▒░▓ + ▒░▒ █▒▒▒█ ▓█ ░▓▓░ ▒█▓▒ ░██ ▓▒▒ + ▒▒▒▒░ ██ ░ ░▓██▒▓▓▓ █░ + ▒█▒▒▒█ ▒██ ░██ + █ █▓ ██▒ ▒▓██ █▒▓ + █▓███ █░▓▒█▓▓▓▒█ ███ + ░ ░▒▓▒▒▒▓▒▒▓▒█░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_23.txt b/codex-rs/tui2/frames/blocks/frame_23.txt new file mode 100644 index 00000000000..39a6c556444 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_23.txt @@ -0,0 +1,17 @@ + + ▒██▒▒████▒█▒▒ + ▒▒░█░▒▒█▒▒▒█░▒░█░█▒ + █ █░██▓█░ ░▓█░▒▓░░█ + ▓▓░█▓▓░ ▒▓▓▒░░▓▒ + ▓▓░░▓█ █▓████▓█▒░▒ + █▒░ ▓░ ▒█████▓██░░▒░█ + ░░░ ░ ▓▓▓▓ ▒░░ ░██ + ░▓░ ░ ░ ░█▒▒█ ░ █▓░ + ▒ ▒ ░█░▓▒▒▒▒▒▓▒░▒█░▒ ▒▒ ░ ░░░ + ░▒▒▒░ ▒ ▓░▒ ▒░▒▒█░ ▒▒░ + ▓█░ ░ ░ █░▓▓▒░▒▓▒▓░ + █░░▒░▓ █▓░▒▒▓░ + ▒ ░██▓▒▒ ▒▓ ▓█▓█▓ + ▒▒▒█▓██▒░▒▒▒██ ▓▒██░ + ░ █▒▒░▒▒█▒▒██░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_24.txt b/codex-rs/tui2/frames/blocks/frame_24.txt new file mode 100644 index 00000000000..90ccc262f07 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_24.txt @@ -0,0 +1,17 @@ + + ▒░▒▓███▒▒█▒ + █ ▒▓ ░▒▒░▒▒██▒██ + █ █▓▒▓█ ░ ▓░▓█░███ ▒ + ██▓▓█▓░▒█▒░░▓░ ▒█▒░▒▒█ + █ ▓▓▒▓█ ░ ▓▒▒░░░▒░██ + ░█▒█▒░ ███▓ ▓░▓ ▓ ▒ + ░ ░░ █▓▒█▓ ▓▒▒░▒▒░▒ + ░ ▒░░ ░█▒▓▒▒░░▒▓▓░░░ + ░▓ ░▓▓▓▓██░░░██▒██▒░ ░ ░░ + ▒ ▓ █░▓██▓▓██░▓▒▒██░ ░█░ + ▒ █▒░▒█ ░ ▒█▓█▒░▒▓█░ + ▒ ▒██▒ ░ ▓▓▓ + ▒▓█▒░░▓ ▒▒ ▒▓▓▒█ + ▓▓██▒▒ ░░▓▒▒▓░▒▒▓░ + █▓▒██▓▒▒▒▒▒██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_25.txt b/codex-rs/tui2/frames/blocks/frame_25.txt new file mode 100644 index 00000000000..d8fd5b45a8f --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_25.txt @@ -0,0 +1,17 @@ + + ▒█▒█▓████▒ + █ ███░▒▓▒░█░░█ + ▓░▓▓██ ▓░█▒▒▒░░░▒ + ░██░ ▓ ▒░ ▒░██▒▓ + █▒▒▒█▓█▒▓▓▒░ ░▓▓▒▓█ + ▒█░░░▒██▓▒░▓ ▓░█░▓▓░█ + ░▓░█░ ░▒▒▓▒▒▓░▒▓▒ ░▒░ + ░░░▓░▓ ░▒▒▒▓░▒▒░▒░░▒ + ▒█▒░ ░▒▒▒▒▒▒█░░▒▒░██░▒ + ▓▓ ░▓░█░▒░░▓█▒░▒█▒▓▒░ + ▒░█▓▒░░ ██▓░▒░▓░░ + ░▒ ░▓█▓▒▓██▓▒▓█▓▓░▓ + ▒░▒░▒▒▒█▓▓█▒▓▒░░▓ + ▒▓▓▒▒▒█▒░██ █░█ + ░█ █▒██▒█░█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_26.txt b/codex-rs/tui2/frames/blocks/frame_26.txt new file mode 100644 index 00000000000..a4734b4486d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_26.txt @@ -0,0 +1,17 @@ + + ▒▓███ ██ + ▓█░▓▓▒░█▓░█ + ▓█ ░▓▒░▒ ▒█ + ▓█ █░░░▒░░▒█▓▒ + ░▒█▒░▓░ █▒▓▓░▒▓ + ▒ ░▓▓▓ █▒▒ ▒▒▓ + ░ ██▒░░▓░░▓▓ █ + ▓▓ ▒░░░▒▒▒░░▓░░ + ░ ▓▒█▓█░█▒▒▓▒░░ + ▓▒░▓█░▒▒██▒▒█░ + ░░ ▓░█ ▒█▓░█▒░░ + ▒▒░░▓▒ ▓▓ ░░░ + █ █░▒ ▒░▓░▓█ + ░ █▒▒ █▒██▓ + ▒▓▓▒█░▒▒█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_27.txt b/codex-rs/tui2/frames/blocks/frame_27.txt new file mode 100644 index 00000000000..b99e90e6d43 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_27.txt @@ -0,0 +1,17 @@ + + ▓█████ + ░▓▓▓░▓▒ + ▓█░ █░▓█░ + ░░░▒░░▓░░ + ░ ░░▒▓█▒ + ░▒▓▒ ░░░░░ + ▒ ░░▒█░░ + ░ ░░░░▒ ░░ + ░▓ ▓ ░█░░░░ + █▒ ▓ ▒░▒█░░ + ░▓ ▒▒███▓█ + ░░██░░▒▓░ + ░▒▒█▒█▓░▒ + ▒▒▒░▒▒▓▓ + █▒ ▒▒▓ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_28.txt b/codex-rs/tui2/frames/blocks/frame_28.txt new file mode 100644 index 00000000000..de6db173b46 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_28.txt @@ -0,0 +1,17 @@ + + ▓██▓ + ░█▒░░ + ▒ ▓░░ + ░▓░█░ + ░ ░░ + ░ ▓ ░ + ▒░░ ▒░ + ░▓ ░ + ▓▒ ▒░ + ░░▓▓░░ + ░ ▒░ + ░▒█▒░ + ░▒█░░ + █▒▒▓░ + ░ ▓█░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_29.txt b/codex-rs/tui2/frames/blocks/frame_29.txt new file mode 100644 index 00000000000..d7b871c9c33 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_29.txt @@ -0,0 +1,17 @@ + + ██████ + █░█▓ █▒ + ▒█░░ █ ░ + ░░░░▒▒█▓ + ▒ ░ ░ ░ + ░█░░░ ▒▒ + ░▒▒░░░ ▒ + ░░▒░░ + ░░░█░ ░ + ▒░▒░░ ░ + █░░▓░▒ ▒ + ░▓░░░ ▒░ + ░░░░░░▒░ + ░▒░█▓ ░█ + ░░█ ▓█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_3.txt b/codex-rs/tui2/frames/blocks/frame_3.txt new file mode 100644 index 00000000000..833b2b3db2e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_3.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓██▒▒▒▒█▒ + ▒██▓▒░░█░ ▒▒░▓▒▒░██▒ + █▓▓█▓░█ ░ ░ ░ ███▓▒░█ + ▓█▓▒░▓██▒ ░▒█ ░░▒ + █▓█░▓▒▓░░█▒▒ ▒▒▒░░▒ + ▓░▒▒▓ ▓█░▒▓▒▒ ░ ▒▒░ + ▒█ ░ ██▒░▒ ░█ ▓█▓░█ + █▓░█░ █▓░ ▓▒░ ░▒░▒░ + ▓ █░ ▓░██░░█▓░▒██▒▒▒██▒░▒ ▓░ + █▒▓▒█ ▓▓█▓▓▓░ ░█░▒▒█ ▒▓█▓▒░░▒░░ + █▒░ ░ ░░██ ███ ███▓▓▓█▓ + ██░ ▒█ ░ ▓▒█▒▓▓ + ▒▒▓▓█▒█ ██▓▓ █░█ + ▒▒██▒██▒▒▓▒▓█▓▒█▓░▒█ + ░███▒▓░▒▒▒▒░▓▓▒ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_30.txt b/codex-rs/tui2/frames/blocks/frame_30.txt new file mode 100644 index 00000000000..9c27cf67d0f --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_30.txt @@ -0,0 +1,17 @@ + + ▒▓ ████ + ▒▓▓░░▒██▒▒ + █▒░█▒▒░██▒ + ░░▒░▓░▒▒░▒ ▒█ + ▒█░░░▒░█░█ ░ + ░█░▒█ █░░░░▓░ + ▒▓░░░▒▒ ▒▓▒░ ▒░ + ░ ██▒░█░ ░▓ ░ + ░▒ ▒░▒░▒▓░█ ░ + ░░▒░▒▒░░ ██ ░ + ▒░░▓▒▒█░░░█░░ + ░█▓▓█▓█▒░░ ░ + ▒░▒░░▓█░░█░▓ + █▒██▒▒▓░█▓█ + ▒▓▓░▒▒▒▓█ + ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_31.txt b/codex-rs/tui2/frames/blocks/frame_31.txt new file mode 100644 index 00000000000..c787451d71c --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_31.txt @@ -0,0 +1,17 @@ + + ▒▓▓████▒█ + ▒██▓██▒ █▒███ + █░▒▓▓█▒▒░▓ ░▒█▒ + █░▓█▒▒█▓▒█▒▒░▒░░▒ + ▒░░░░█▓█▒▒█ ▒░▓▒▒ + ▓░▒░░▒░█ ▒▓██▓▓░█ ░ + ▓░░ ░▒█░▒▓▒▓▓█░█░▓░ + ▒▒█ ░░ ░▒ ░▒ ░░▒▓░ + ░▒█▒░█▒░░░▓█░░░▒ ░ + ░░░▓▓░░▒▒▒▒▒░▒░░ █ + ▒█▒▓█░█ ▓███░▓░█░▒ + ░░░▒▒▒█ ▒▒█ ░ + ▓░█▒▒ █ ▓ ░█░▓░ + ▓░▒░▓▒░░█░ █░░ + █ ▒░▒██▓▓▓█ + ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_32.txt b/codex-rs/tui2/frames/blocks/frame_32.txt new file mode 100644 index 00000000000..e5e7adf64d4 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_32.txt @@ -0,0 +1,17 @@ + + █████▓▓░█▒ + ▓█░██░▒░░██░░█ + ▓▒█▒▒██▒▓▓░█░█▒███ + █▓▓░▒█░▓▓ ▓ █▒▒░██ █ + ▓▓░█░█▒██░▓ █░█░▒▓▒█▒█ + ▒▓▒▒█▒█░░▓░░█▒ ░█▓ █ + █░ ▓█░█▒░░██░█▒░▓▒▓▓░█▒ + ░░░█▒ ▒░░ ▓█░▓▓▒ ▒░ ░ + ▒░░▓▒ █▒░ ▒▒░███░░░▒░ ▒░ + █ ▒░░█▒█▒▒▒▒▒▒░░█░▓░▓▒ + █▒█░░▓ ░█ ███▒▓▓▓▓▓▓ + ▒█░▒▒▒ █▒░▓█░ + ███░░░█▒ ▒▓▒░▓ █ + ▒▓▒ ░█░▓▒█░▒█ ▒▓ + ░▓▒▒▒██▓█▒ + ░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_33.txt b/codex-rs/tui2/frames/blocks/frame_33.txt new file mode 100644 index 00000000000..31a607b29cb --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_33.txt @@ -0,0 +1,17 @@ + + ▒██▒█▒█▒░▓▒ + ▒██░░▒█▒░▓░▓░█░█▓ + ▒▓▒░████▒ ░ █▓░░█ █ + █▒▓░▓▒░█▒ █░░▒▒█ + ▒▓░▓░░░▓▒▒▒ ░█▒▒▒ + ▓▓█ ▒▒▒▒░▒█ ▓▒▓▒▒ + ░░█ ▒██░▒░▒ ░█░░ + █░██ ███▒▓▒█ ▒ ░█ + ░░░ ░ █░ ▓████▓▒▒█░░█▓▒░▒░ + ▒▓░█ ▓▓█▓░░░▒▒▒▒▒░░█▒▒▒░░▓ + ▒▒▒█ ░▓░▓ ▓ ███ ░░█▓▒░ + ▒█▒██ █ ▓▓▓▓▒▓ + █▒ ███▓█ ▒█░█▓█▒█ + ▒░ █▒█░█▓█▒ ▓█▒█░█ + ▒▒██▒▒▒▒██▓▓ + ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_34.txt b/codex-rs/tui2/frames/blocks/frame_34.txt new file mode 100644 index 00000000000..db99cb73d61 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_34.txt @@ -0,0 +1,17 @@ + + ▒█▒████▒░█▒ + ▒███▓▒▓░ ░██▒██▓█▒▒ + ▒▓▓█░█ ▓░█░ ░▒▒▒█ ███ + █▓▒░█▒▓█▒ █░██▒▒ + ▓▓░▒▓▓░ ░ █ ▒▒█▒▒ + █▓▒░░▓ ▒▒ ░▒█▒ ▒█▒░▒ + ░█▒░▒ █▒▒█░▒▒ ░▓░▒ + ▒░▒ ▓ ░█▒░▓ ░ ▓ ▒▒ + ██▓▓ ▓▒▓▓ ▒▒▒██████░▒▒ ░▒░ + ░░▒█▓██▒ ▓▓█░░░▒░▓▒▒▒█▓▒░░░░▒ + ▓▒▒█ ░▒░█▒ ██░░░░▒ █▓█▒░█ + ▓█▒▓▒▒▒ ▓▓▓░▓█ + ▒█░░█▒▓█ ▒█▒ ▒▓█░ + ▓▒▓░ ░██▓██▒█▒█░██▓█ + ░▒▓▒▒▒▒▒▒▓▒█▒▒ + ░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_35.txt b/codex-rs/tui2/frames/blocks/frame_35.txt new file mode 100644 index 00000000000..814188563de --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_35.txt @@ -0,0 +1,17 @@ + + ▒██▓▒███▒██▒ + ██▒█▓░███ ░█░▓ ░█▒▒ + ▒▓▓░▓██░▒█ ░ ░ █▒█▓ ░██ + █▓▓█▓█▓█▒ ██▒▒░▒ + ▓▓░░▓▓▒ ▒██ ░▒█░█ + ▓▓▓▓█░ █░▒ ▓▓█▒ ░▒▒░ + ▒ ▓▓ ▒▒ ██▒▓ ░▒▒▒ + ░░░▓ ▓▒▒▓▓█ ▓ ▓ + ▓ █▒ █░░▓▓ ▓░▒▒▒▓▒▒█░░ ░░▒█ + ░█▒▓█ ▓▓▓ ██▓░▓ ▒█▒▒▒▒▓ ░▓█ ░█ + ▓░▒██▓▒▒░▓▒░ ░ ▒▒▒▒█▒▒█▓▓▒█░ + ▓▒▒▓░ ▒▓█ █▒ + ▒▓░▒▓█▓█ █▓▓▒███ + ▒▒ ░█░▓▓░░█░▓▓█ ▒▓▓ + ▒░▓▒▒▒▓▒▒███ ▒ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_36.txt b/codex-rs/tui2/frames/blocks/frame_36.txt new file mode 100644 index 00000000000..cde83b56f41 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_36.txt @@ -0,0 +1,17 @@ + + ▒▒█▒████▒██▒ + ▒▒ ▒█▓▓▓█▒█▓██ ███▒ + █▒█▒███▓█ ░░ ░ █░██░██░█ + ▒░ ██▒▒▒▒ ██░▒ ░ + █▓▒▓▒█░▒░▒█▓ ▒▒▓█ + ▓ █▓░ █▒ ░▓█ ▒▒█ + ░ ▓ ░ ▒ ▒▒ ░▒░█ + ░░▒░ ▒▒ ▒▓▓ ▒░ ░ + ░█ ░ ▓▓ ██ ████▒█████▒ ░▒░░ + ▒█░▒ █░▒▒▓░▓ ░░▒▒▒▒▒▒▒░░ ▒▓█░ + █ █░▒ █▒█▓▒ ██▒▒▒▒▒ ░█ ▓ + ██ ▒▓▓ █▓░ ▓ + ▒▓░░█░█ ███ ▓█░ + ██▒ ██▒▒▓░▒█░▓ ▓ █▓██ + ░██▓░▒██▒██████ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_4.txt b/codex-rs/tui2/frames/blocks/frame_4.txt new file mode 100644 index 00000000000..7ad27d16e74 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_4.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓█▒▒█▒██▒ + ▒▓ ██░▓ ░▒▒▓█▓░ ▓██▒ + ██▒░░░██ ░ ░▒░▒▒█░▓▒▒▒ + ▓▓░█░ ▓██ ░██▒█▒ + ▓░▓▒░▒░▒▓▒░█ ▒ ▒░█▒ + ▓░░▒░ █▒░░▓█▒ █ ▒▒░█ + ▒░▓░ ███▒█ ░█ █ ▓░ + ░▓▒ █░▓█▒░░ ░░░ + ▒░ ░▒ ▓░▓ ▒▓▓█░███▒▒▒▒██ ░░█ + ░▒▓ ░ █▓▓▓█▒░░▒▒░█▓▒█▓▓▒▓░▓▓ ░ + ░░▓█▒█▒▒█▒▓ ████████▒▓░░░░ + █░▒ ░▒░ █▒▓▓███ + ▒▒█▓▒ █▒ ▒▓▒██▓░▓ + ░░░▒▒██▒▓▓▒▓██▒██▒░█░ + █▒▒░▓░▒▒▒▒▒▓▓█░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_5.txt b/codex-rs/tui2/frames/blocks/frame_5.txt new file mode 100644 index 00000000000..24f98439548 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_5.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▓█▒▒▒██▒ + ▒█ █▓█▓░░█░▒█▓▒░ ██ + █▒▓▒█░█ ░ ▒▒░█▒ ███ + █░▓░▓░▓▒█ ▓▒░░░░▒ + █▒▓█▓▒▒█░▒▒█ ░ ▒░▒░▒ + ░░░░▓ ▒▒░▒▓▓░▒ █▓░░ + ░▓░ █ ░▒▒░▒ ░█ ██░█░█ + ░▓░▒ █▒▒░▓▒░ █░▒░ + ░█░▒█ ▓▒░ █░█▒▒░█░▒▒▒██▒ ░▓░ + ▒▒░▒██▓██ ░ ▓▓▒▒▒█▒▓█▓░▓█░░ + ▒█░░█░█▒▒▓█░ ██ █░▓░▒▓ + ▒▒█▓▒▒ ░ ▓▒▓██▒ + ▒▓█▒░▒█▒ ▒▒████▓█ + ▒░█░███▒▓░▒▒██▒█▒░▓█ + ▒▓█▒█ ▒▒▒▓▒███░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_6.txt b/codex-rs/tui2/frames/blocks/frame_6.txt new file mode 100644 index 00000000000..fe185a75737 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_6.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▓█▒▒██▒▒ + █▒▓▓█░▒██░██▓▒███▒ + ███░░░█ ░ ░▓▒███▓▒▒ + ▓█░█░█▒▒█ ▒█░░░░█ + █▒░░░█▒▒██▒ ▓▒▒░▒█ + ▓▓▓░▓░▒█▓░▒▒░█ ▓▒▒▓░ + ▒ █░░ ▒▒░▓▒▒ ▒█░▒░ + ░ ░░░ ▒░▒░▓░░ ░█▒░░ + ▒▓░▓░ ▓█░░█▓▓█▒░█░▒▒██▒▓▒▓░ + ░░▒█▓▒▒▒▓█ ░▓▒██░░█▓▒▒▒░█░▒ + ▓ ░ ▓░░░▓▓ █ ██ ░▒▒▓░ + █ ▓ ▓█░ █▓▒▓▓░░ + ▓░▒▒███ ▒█▒▒▓███ + ░ ░██ █ ▓░▒▒████ ▓▓█ + ▒▓▓███▒▒▒░▒███ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_7.txt b/codex-rs/tui2/frames/blocks/frame_7.txt new file mode 100644 index 00000000000..7441f97e96e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_7.txt @@ -0,0 +1,17 @@ + + ▒▓░▓██▒▒██▒ + ██░█▒░███▒▒▒▓ ░██ + █ █░░░█░ ░▒░░ █▓▒██ + ▒▒░░░░▓█ ▒░▒█░▓█ + ░█░█░░▒░▓▒█ ▓ █░░▒ + ░ ▓░░ ░█▒▓░▒ █▓░░░ + ░▒ ░ ▒▒░▒░▒░ ██▒░░ + ▒ ▓░░ ▒█▓░█░░ █ ░░░ + ▓ ░█ █ ▒▓░▒▓░░▓▓▒░░▒▓█▒░░ + ░██░░▒▓░░▓█░▓▒░░▒▒█▒█▓▒░▒░ + ▒ ▒▒▓█░█▒▓ ██████ ▒▓░░ + █▒ ▓▒▓▒░ █ ▓▓▓▓█ + █▓██▒▒▒▒ █▒░██▓██ + ▒▒█▒░█▒▓░▒▒▒██░██▓ + ░█ ░▓░▒▒█▒▓██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_8.txt b/codex-rs/tui2/frames/blocks/frame_8.txt new file mode 100644 index 00000000000..ea88b095382 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_8.txt @@ -0,0 +1,17 @@ + + ▒▒█▒▓██▒██▒ + █ █▓░░░█▒▒ ░ █ + ▒░▒█░▓▓█ █ ░▓░█▒█▒█ + ▒█▒█▓░██░ █ ▒▒░░▒ + █ ▓░▓█▒░▓▒ ▓█▒░░█ + ░██░▒▒▒▒▒░▒█ ▒█░░░ + ░█░░░ █▒▓▒░░░ ░▒░▓░█ + ▒█░░▓ ░█▒▓░██▓ ▓░▓░░ + ▒ ▒░░▒▒ ▓█▒░░▓█████▒░░░ + ▒█▓▒▒░ █░█░░▓░▒▒▒░░▒█ + ▓▓▒▒░▒░░░▓█▒█▒█ ▒█ ▓▒░ + ██ ░▒░░░ ▓█▓▓▓█ + █▒▒█▒▒▒▒ ▒▓▒▒░█▓█ + ▓▓█░██ ▓▓██▓▓▒█░░ + ░░▒██▒░▒██▓▒░ + ░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_9.txt b/codex-rs/tui2/frames/blocks/frame_9.txt new file mode 100644 index 00000000000..9066ba1beda --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_9.txt @@ -0,0 +1,17 @@ + + ▓▒▒█▓██▒█ + ▓█▒▓░░█ ▒ ▒▓▒▒ + ▓ █░░▓█▒▒▒▓ ▒▒░█ + ░░▓▓▒▒ ▒▒█░▒▒░██ + ▓█ ▓▒█ ░██ █▓██▓█░░ + ░ ░░░ ▒░▒▓▒▒ ░█░█░░░ + ░ ░█▒░██░▒▒█ ▓█▓ ░░░ + ░ ░▓▒█▒░░░▒▓▒▒▒░ ░░ + █░ ▓░ ░░░░█░░█░░░ + ░▒░░░▒█░▒░▒░░░░▒▒░░░ + ░▒▓▒▒░▓ ████░░ ▓▒░ + ▒░░░▒█░ █▓ ▒▓░░ + ▒█▒░▒▒ ▓▓▒▓░▓█ + ▒▓ ▒▒░█▓█▒▓▓█░░ + █▓▒ █▒▒░▓█▓ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_1.txt b/codex-rs/tui2/frames/codex/frame_1.txt new file mode 100644 index 00000000000..63249f42421 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_1.txt @@ -0,0 +1,17 @@ + + eoeddccddcoe + edoocecocedxxde ecce + oxcxccccee eccecxdxxxc + dceeccooe ocxdxo + eedocexeeee coxeeo + xc ce xcodxxo coexxo + cecoc cexcocxe xox + xxexe oooxdxc cex + xdxce dxxeexcoxcccccceco dc x + exdc edce oc xcxeeeodoooxoooox + eeece eeoooe eecccc eccoodeo + ceo co e ococex + eeoeece edecxecc + ecoee ccdddddodcceoxc + ecccxxxeeeoedccc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_10.txt b/codex-rs/tui2/frames/codex/frame_10.txt new file mode 100644 index 00000000000..fe5e51b9845 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_10.txt @@ -0,0 +1,17 @@ + + eccccecce + ccecccexoeco + eeoxxoxxoxceoo + xeeoexdeoeocceeo + o dxxcxe cooeoxo + xe cxcxooe eecx + e xcccxxxxc xoo + c xxecocxxoeeoexx + c xe eexdxxcecdxx + x oxeoxeoeceeexce + o cxxxxxcc eocexe + eecoeocc exccooo + xc xxxxcodooxoe + deccoxcde ooc + co eceeodc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_11.txt b/codex-rs/tui2/frames/codex/frame_11.txt new file mode 100644 index 00000000000..48e507a84a1 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_11.txt @@ -0,0 +1,17 @@ + + occcccce + oc dxxxeeo + oceexxdecoeo + xeexxddoedoo + ecodexcecdexxo + xcexxceddxeoxx + cc oxxxxxxexde + x xxoxxeo xcx + o cxoxxcocxex + cc exodocoxexe + ceo xxxxdoxeex + eeooxecoccdxe + e cxeeeexdc + ec cxxoeoce + ee cccece + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_12.txt b/codex-rs/tui2/frames/codex/frame_12.txt new file mode 100644 index 00000000000..29de69516a3 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_12.txt @@ -0,0 +1,17 @@ + + ccccco + odeeoxoe + c xoeco + ocxxxddcx + x cxxxxoox + xcoocecexc + x xoexxe + x ocexxc + co xoxxcxx + x oxcdce + xo xcdcco + o cx eox + o ccxocex + ceocoxexe + e cxeoo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_13.txt b/codex-rs/tui2/frames/codex/frame_13.txt new file mode 100644 index 00000000000..67fe336a137 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_13.txt @@ -0,0 +1,17 @@ + + occco + xeexx + xeexc + xccxe + c xx + cdoxx + o xx + c cx + oc exo + xc cdx + ceoo xe + xeeex + xcoxe + ceexd + o ocd + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_14.txt b/codex-rs/tui2/frames/codex/frame_14.txt new file mode 100644 index 00000000000..f8d32cd6d19 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_14.txt @@ -0,0 +1,17 @@ + + ccccd + ooeeoe + xexxo x + xxoxcexo + xxxe x + xcxx cx + xxxx o c + xxexe e + xxxx c + ceoo do + exccooox + xcxxeeex + o cxddde + xeoceeo + ec cdo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_15.txt b/codex-rs/tui2/frames/codex/frame_15.txt new file mode 100644 index 00000000000..2e14341237a --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_15.txt @@ -0,0 +1,17 @@ + + cccccxe + eodxxedco + ooxcdexccx + xoe ooooeex + xxdcdexxocex + exxoxxoox c + xx xxxxxxox + xxoxxcxxx cox + xxcoocxxxeodx + xexdoxexco ox + xoxxxxex e d + xccoexxeo d + cxeo oooe de + xexxeeoceo + eeceeeeo + ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_16.txt b/codex-rs/tui2/frames/codex/frame_16.txt new file mode 100644 index 00000000000..c90ce92cb6d --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_16.txt @@ -0,0 +1,17 @@ + + edcccccxe + oexxcxxexde + xooceodexx ce + ooo dceexexxccx + xxdeoccdxxcoxee + xxxcxc xed x xox + eex oeoxxxxocco x + xod xexxoxxxcd ex + eexxxcxoexxccc o + cceeoddecxoex oex + xxxcccocexdcdoxxe + xxc xe eooo o + exc x oooeox + exxcecxoocex + cdoeddeedc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_17.txt b/codex-rs/tui2/frames/codex/frame_17.txt new file mode 100644 index 00000000000..e1f2bb6d96c --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_17.txt @@ -0,0 +1,17 @@ + + odcccddxoe + edccxxxcdcxoceo + oceoeddecocxxxece + oxoeoxcee cxdexxxde + xoe x xcoedeoo o + edcooe odox oodoxoo + c dox oooxe ccxxodx + ocdx ooxxoxoxxddc + oocoeddcxeexeedexxx x + xcedeexoceoxxe eccce + eeeoccccccceexcooe ec + exxec eoxxe d + eee cee ocooeeo + o xccdeceedcdxc + ecdoeocxcecc + e \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_18.txt b/codex-rs/tui2/frames/codex/frame_18.txt new file mode 100644 index 00000000000..be64251770d --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_18.txt @@ -0,0 +1,17 @@ + + eddcddcdxoe + eccedoccxeeoccdde + eodxcccdcocoeccooe c + oxxcooecc ceeeodxedeeeo + eeoo ox ecceeoxoxeedeee + oex ooxoeeeoocoxcooeoeox + xxedo cocoxceoccxdxdo + ceoxx eecxxde xdxc + ecc oedddddcxxoxcoeo xcxe + eeexcec xxoeeeexxxedxee o + xoxeeccccccce eeeoxocoeoe + ee oeo eeccocec + eecceeo eceeoeoe + cxoccccdddecceoeoc + cxxeoeeooccdcc + e \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_19.txt b/codex-rs/tui2/frames/codex/frame_19.txt new file mode 100644 index 00000000000..89041571213 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_19.txt @@ -0,0 +1,17 @@ + + eeddcxcddxoe + ecxxxeodddeceoxcoo + ocddocxcce ecdoecde + odxcoee eddcoexco + xxoeoe oxecocxe xeo + xeocc excxo oo cocx + edxxc oceoxcoe odocx + xxxx xdcexco x xxx + xcxeoddddddxxxxccdcxd e cxx + edooxdcoecceoeo ee deeeoooxe + cecocxcccccccc eeeoxoo ooc + eeecee eooeooc + c eexxco oddooxde + ccoxcoxceeddocc dcxc + cxoedoceooecoe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_2.txt b/codex-rs/tui2/frames/codex/frame_2.txt new file mode 100644 index 00000000000..a3c0663db46 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_2.txt @@ -0,0 +1,17 @@ + + eoeddcdddcoe + ecoocdcxxxdxxdecxcce + oxcxeccxcee eccdcoxxdxo + exoeoccooe ooexoxo + oecocexeeeee eoxexo + cocce xcecoec eexcx + oxccx eoxdxexo ocxcx + xc ee oxcxxdc xcoox + cccdx dxeeexcoxccccccccoxexxc + edcx oxxc oc xdeeeeeooeexco x + eee c ceooxc ecccccccccxocxx + ceeooo e ocdooc + oeeexco odec exc + exedeecccdddddodceexxc + eccccxxeeeexdocc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_20.txt b/codex-rs/tui2/frames/codex/frame_20.txt new file mode 100644 index 00000000000..cea5393f758 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_20.txt @@ -0,0 +1,17 @@ + + eecdxxdcdoee + oddcdoeodddxxeececo + oocecccxcc ecececcxce + excecxc eocxeocee + ex oxc eo exxecexxe + oeoxc cccdxco cexxe + dxdcx oc occe oexo + xeeoe ccddxco xxcx + xoxxdoddddddddeocdeeeec o xe + cxexec oeeeeeexe ceecxde oo xx + eoeecccccccccc eodxxox oe + c ecoo eocoxo + eeecoxe odcedcc + eooocxceddodcxceoocc + eccxe deeeexccc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_21.txt b/codex-rs/tui2/frames/codex/frame_21.txt new file mode 100644 index 00000000000..efa6d610d9f --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_21.txt @@ -0,0 +1,17 @@ + + eeodcddcdcoee + occeeeecxdxcdeeocce + dceeccece eexcceeco + ocxdcc eodcodco + oooce oxoee eeeeo + ocox occeoo eeeo + xcxe e oeooc edec + ee ed cxo x x + x x ocdddddccc exocxo do x + x xe xe eox ececxo ocoo + d co eeccc ce cceod oe o + cc dde ecc o + ce eoe eodcc oe + cde ccccdxxdddccc oe + cccdceeeeoedcce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_22.txt b/codex-rs/tui2/frames/codex/frame_22.txt new file mode 100644 index 00000000000..91c9c2ecaae --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_22.txt @@ -0,0 +1,17 @@ + + eocdcddcdcoe + ecocxoeoxoexxdxcocce + odcdxecce ecceccceeco + dcxccc ooxxxece + exxoc oeoxdxoodcx + excoe oxoxdeoe exedx + xcceo xcxecoc xxox + xxdxe xexxee xexcx + xxoco cxddddddcceecxe eo exdc + exd ceeeo oocxoox ecdecxoo oed + eeeex cccccccce edcceooocoe + eceeeo ecocxoc + cccd cce eococceo + cdccoccxddcddodccccoc + cxcxedeeeodeodce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_23.txt b/codex-rs/tui2/frames/codex/frame_23.txt new file mode 100644 index 00000000000..5b5f1be139d --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_23.txt @@ -0,0 +1,17 @@ + + eocedccccdcee + edxcxeeoeddoxexcxce + occxcodce cxdcxedxxo + odxcdoe eddexxde + ooxeoc ooooccocexe + oexcoe ecccccoccxxexo + exxcx odoo exe c xcc + xox x xcxoeeo x cox + ece xcxddddddddxecxecee x xxx + xeeexcdc oee exeeox eex + ocx x eccccccc ceoddxeoeoe + oxxexo ooxeeoe + e xocoee eocdcoco + edecdccexddecccoecce + cx cdexeeceecce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_24.txt b/codex-rs/tui2/frames/codex/frame_24.txt new file mode 100644 index 00000000000..c0269d8eda6 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_24.txt @@ -0,0 +1,17 @@ + + exedcccddoe + oceocxeexddcoecc + occdeoccx oedcxcco e + ocooooxdoeexoe ecexeec + o ooeoo eccoeexeeexoc + xoecee cooo oxd oce + x xx ooeoocoeexeexe + x exx xodoeexxeooexx + xo xddddccxxxccecoex x xx + e o cxoooddooxoeeccx xcx + e cexeccccccce eoocexdooe + e eoce x codo + eoceexo edceodec + oocoeecxxddddxeeoe + cdeccdeeeddcc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_25.txt b/codex-rs/tui2/frames/codex/frame_25.txt new file mode 100644 index 00000000000..5b040665d0b --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_25.txt @@ -0,0 +1,17 @@ + + ecdcdcccce + o coceedexcxxo + oxoooocoxcedexxxe + xccx o dx cexoceo + oeeeoocedoexc xooeoc + eoxxxeccoexd oxoxooxo + xoxcx xeeoeeoxeoecxdx + xxxoxoc xedeoxeexdxxe + ecexcxeeddddcxxeexccxe + oocxoxoxexxdcexecdoex + excoexecccccccoxexoxe + xecxdcdeoocdeooooxo + eeexeeecdooeoexxo + eodeeecdxcc cxc + xoccecoecxc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_26.txt b/codex-rs/tui2/frames/codex/frame_26.txt new file mode 100644 index 00000000000..1592c09e8cf --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_26.txt @@ -0,0 +1,17 @@ + + edccccco + ocxdoexcdxo + occcxdexecceo + dccoxxxexxecoe + xeoexoxcceodxed + e cxodocceeceeo + x ccdxxoxxddcc + oo exxxeedxxoxx + x oecdcxcddoexx + oexooxeeoceecx + xecoxcceooecexx + eexxoe oocxxe + c cxe eeoxoo + xcceecceccd + eodecxeec + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_27.txt b/codex-rs/tui2/frames/codex/frame_27.txt new file mode 100644 index 00000000000..5279157c040 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_27.txt @@ -0,0 +1,17 @@ + + dcccco + xddoxoe + dce cxocx + xxxexxdxx + x exeocd + xeoecexxxe + d cxxecxx + x exxxdcxx + xo o xcxxxx + cd ocexecxx + xo eecccoc + xxccxxeox + xddcdooxe + eeexedoo + cec eeo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_28.txt b/codex-rs/tui2/frames/codex/frame_28.txt new file mode 100644 index 00000000000..ea695865f4a --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_28.txt @@ -0,0 +1,17 @@ + + occd + xcexe + d dxe + xoecx + x xx + x ocx + exx ex + xoccx + oe ex + xxodxx + x ex + xdcdx + xdcxx + ceeox + x ocx + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_29.txt b/codex-rs/tui2/frames/codex/frame_29.txt new file mode 100644 index 00000000000..328d426a415 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_29.txt @@ -0,0 +1,17 @@ + + ccccco + oxco ce + eoxx ccx + xxxxeeoo + e xcx x + xoxxx ee + xeexxx e + xxdxx + xxxcx e + exdxx e + cxxoxe d + xoxxx ex + xxxxexex + xdxcocxc + xxc oo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_3.txt b/codex-rs/tui2/frames/codex/frame_3.txt new file mode 100644 index 00000000000..3e9206577af --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_3.txt @@ -0,0 +1,17 @@ + + eoddccddddoe + ecooexxcxcddxdeexcce + odocdxccce ecx cccoexo + ocoexdoce edc xxe + cocxoeoxxcee eeexxe + oxeeo ooxedee x eex + dc x ccexecxo ocoxo + ooxox ooxcoex xexdx + occx dxccxxcoxdcceeeccexecdx + oedeo oocoddx xcxeeo doodeexexe + cex x cxxcoc cccccccccoooooo + ccx ec e oeceoo + deooceo ocdocoxc + decoecceddddoddcdeecc + ecccedxeeeexdoec + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_30.txt b/codex-rs/tui2/frames/codex/frame_30.txt new file mode 100644 index 00000000000..b9da98c5c37 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_30.txt @@ -0,0 +1,17 @@ + + edcccco + eodxxeccde + ccexoeexcoe + xxexoxeexe eo + dcxxeexoxo x + xcxec cxxxxox + eoxxxee eoex de + cx ccdxoxcxo e + cxecexdxeoxo e + cxxexeexx co e + exxdeecxxxcxx + xcoooocexxc x + exexxocxxoxo + oeocdeoxooc + eooxeeedc + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_31.txt b/codex-rs/tui2/frames/codex/frame_31.txt new file mode 100644 index 00000000000..baef07474cb --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_31.txt @@ -0,0 +1,17 @@ + + eodccccdo + eccdcoeccecco + oxeooodeeocxece + oxoceecdeoeexexxe + exxxxcoceeocexoee + dxeexexccedcoooxocx + oxx xecxeododcxcxox + eeo xxcxe xeccxxeox + xeoexcexxxocxxxe x + cxxxooxxeeeeexexx c + eceocxo occceoxcxe + xxxeeeo edc x + dxcde o o xceoe + dxexoexeoxcoxe + ccdxeccoodc + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_32.txt b/codex-rs/tui2/frames/codex/frame_32.txt new file mode 100644 index 00000000000..c0997d9a140 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_32.txt @@ -0,0 +1,17 @@ + + occccddxoe + dcxccxexxccxxo + oecdeocedoecxcecco + cooxeoedo o oeexco o + ooxoxceccxd ceoxeoeceo + eoeeoecxedxxce xco c + cxcdoecexxooxodeoeooxce + xxxoe cexxcocxdoecexcce + exxoe cexceexcccxxxdxcde + ccceexceceeeeeexxcxdxoe + oecxxo xccccccedooooo + eoxeee oexocx + cccxxxce eoexo o + eoecxcxddceecceo + xddeeococecc + eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_33.txt b/codex-rs/tui2/frames/codex/frame_33.txt new file mode 100644 index 00000000000..cd8691c1502 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_33.txt @@ -0,0 +1,17 @@ + + eocdcdcdxoe + eccxxecdxdxoxcxco + eoexcccodce ccoxxcco + oeoxoexoe cxxeec + eoxoexxoeee xceee + ooo eeeeeeo oeoee + xxc eocxexe xcxx + cxoo occeodo ecxc + xxx x oe ocooodddcxxcoexex + eoxccodooexxeeeeexxceeexxo + edeo xoxo o ccccccc xxooee + ececoco oododo + ceccocdo ecxoocec + exccecxodcecdoecxc + cddcoeeeeccdoc + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_34.txt b/codex-rs/tui2/frames/codex/frame_34.txt new file mode 100644 index 00000000000..ef8eabf7dc0 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_34.txt @@ -0,0 +1,17 @@ + + eodccccdxoe + ecccoedxcxccdccdode + eoooxccdxce eeeeoccco + ooexceooe cxocee + ooxeoox xc o eecee + ooexeo eecxece eoexe + xcexe ceecxee xdxe + exdcd xcexocx o ee + ocooc oeooceddccccccxeec xee + xxeooooecoocxxxexoeeeooexxexe + oeeo xexce ccceeeee oooexc + ooeddee odoxoc + ecexcedo ecdceooe + oeoxcxcodocdcdceccdc + cxeddeeeeeddcde + eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_35.txt b/codex-rs/tui2/frames/codex/frame_35.txt new file mode 100644 index 00000000000..1c53d2373f2 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_35.txt @@ -0,0 +1,17 @@ + + eocddcccdcoe + ocdcoxccccxcxdcxcde + eooxdccxecce eccdcocxco + ooocoodoe cceexe + ooxxooe ceco xecxo + dodoce cxecooce xeex + e oo ee cceo xeee + xxxd oeedoc o co + o oe oxxodcoxddededcxx xxdc + xoedc oodcccoxd eoeeeeocxoc xc + oeeocoeexoee eceeeeceecooeox + coeeox eoc oe + edeedodo odoeccc + ceecxcxodxxcxdocceodc + cexddeeoeecccce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_36.txt b/codex-rs/tui2/frames/codex/frame_36.txt new file mode 100644 index 00000000000..4928a2a9d07 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_36.txt @@ -0,0 +1,17 @@ + + eecdccccdcoe + edccecodocecdcccccce + oeceoccoccee eccxccxocxo + exccceeee ccxecx + ooedecxeeeoo eeoc + o ooe ce cxoo ceec + x d e e cee xexo + xxex ee eoo ex x + xccx oo occcocceccccce xexx + ecxe oxeeoxo exeeeeeeexx eoce + c cxe cecoe ccceeeeec xoco + cocedo ooxco + eoxxcxo occcooe + coecccdedxecxdcocodcc + eccoxeooeooccccc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_4.txt b/codex-rs/tui2/frames/codex/frame_4.txt new file mode 100644 index 00000000000..a5ae50eeae4 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_4.txt @@ -0,0 +1,17 @@ + + eoddcddcdcoe + eocccxo xedocdxcocoe + ocdxxxccce eexeecxoeee + ooxoxcoco cxccece + oxoexdxedexo ecexce + oxxex cexxoce c edxo + cexde ccceccxo o cdx + xoe oxocexx xxx + ex xe dxoceoocxccceeeeoo xxc + xeo x oooooexedexooeodoedxoocx + exdceoeeoeo ccccccccceoxxxe + oxecxee oedoccc + eeode oe eoeocdxo + xxxdecceddddocdccexce + ceexdxeeeeedoce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_5.txt b/codex-rs/tui2/frames/codex/frame_5.txt new file mode 100644 index 00000000000..47abf7a0af6 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_5.txt @@ -0,0 +1,17 @@ + + eodddcdddcoe + ecccocoxxcxdcdexcco + ceoecxcce cedxce oco + oxoxoxodo oexxxxe + oeocoeecxeeo e exexe + xxxxo eexedoxe coxx + xox c eeexecxo ccxcxo + xoxec oedxoex cxex + xoxec oexcoxcdexcxeeecoecxox + eexeoooccc xc ooedeodoooxocxe + eoxxoxoeeoce ccccccccoxdxeo + eecoee e oeocoe + eocexdce edcoccdc + excxoccedxdeocdcexdc + eocecceeeoeocce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_6.txt b/codex-rs/tui2/frames/codex/frame_6.txt new file mode 100644 index 00000000000..ba04c52772f --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_6.txt @@ -0,0 +1,17 @@ + + eodddcddccee + oedocxdccxccdeocce + oooxxxcc ecxodcccoee + ooxcxcedo ecxxxxo + cdxxxceecce oeexeo + dooxoeecdxeexo odeox + e cxx eexoee ecxex + x xxx exdxoxx xcexx + eoxox ocxxcdocexcxeecceoeox + xxeooeeedc xodcoxxodddexoxe + o x oxxxdoc cccccccceeeox + o d ooe odeooxe + oeeecco eceeococ + ecxcocc dxeeoccc ddc + eodccoeeeeeocc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_7.txt b/codex-rs/tui2/frames/codex/frame_7.txt new file mode 100644 index 00000000000..f7dd0de9b60 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_7.txt @@ -0,0 +1,17 @@ + + eoxdccddcoe + ocecexcccdded eco + c oxxxce eexe coeco + eexxxxdc execxoo + ecxcxxexoeo o cxxe + x dxxc ecedxe ooxxx + eecx eexexex ccexx + ecoxx dcoxcxe c xxx + ocxc oceoxeoxxddexxddcexx + xocxxddxxocxoexxeeododexex + e deocxceo cccccccceoxx + ce oeoee ocoodoc + cdoceeee oexococc + eecexcedxeeeccxcco + cxccxdxeeoedcc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_8.txt b/codex-rs/tui2/frames/codex/frame_8.txt new file mode 100644 index 00000000000..e3f93702f72 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_8.txt @@ -0,0 +1,17 @@ + + eecedccdcoe + occoxxxcdd x cc + exdoxooc ocxoxceceo + ececoxocx c dexxe + c oxooexoe ocexxo + xcoxeeeeexec ecxxx + xcxxx cedexex xexoxo + eoxxo xceoxoco oeoxx + e exxee ocdxxococooexxx + ecodexcoxoxxdxdeexxdc + ooeexexxxocececceccoex + cocxexee oooooc + ceeceeee eoeexcoc + odcxoc ddccdodoxe + xxeccexeocode + ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_9.txt b/codex-rs/tui2/frames/codex/frame_9.txt new file mode 100644 index 00000000000..210e417d435 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_9.txt @@ -0,0 +1,17 @@ + + odecoccdo + oceoxxccd eoee + o oxxoceedo eexo + c xxodde eeoxeexco + occdeccxco coccdcxx + x xxxcexedee xcxcxxx + e xcexccxeeocooo exx + x xoeoexxxeodeex xx + coxc oxcxxxxcxxoxxe + xexxxeoxexexxxxeexxx + c eeoeexocccccxxcoex + exxxeoe oo eoxe + ecexee odedxoc + eoceexcocdddcxe + coe ceexdcoc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_1.txt b/codex-rs/tui2/frames/default/frame_1.txt new file mode 100644 index 00000000000..64a140d2b9c --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_1.txt @@ -0,0 +1,17 @@ +                                       +             _._:=++==+,_              +         _=,/*\+/+\=||=_ _"+_          +       ,|*|+**"^`   `"*`"~=~||+        +      ;*_\*',,_            /*|;|,      +     \^;/'^|\`\\            ".|\\,     +    ~* +`  |*/;||,           '.\||,    +   +^"-*    '\|*/"|_          ! |/|    +   ||_|`     ,//|;|*            "`|    +   |=~'`    ;||^\|".~++++++_+, =" |    +    _~;*  _;+` /* |"|___.:,,,|/,/,|    +    \^_"^ ^\,./`   `^*''* ^*"/,;_/     +     *^, ", `              ,'/*_|      +       ^\,`\+_          _=_+|_+"       +         ^*,\_!*+:;=;;.=*+_,|*         +           `*"*|~~___,_;+*"            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_10.txt b/codex-rs/tui2/frames/default/frame_10.txt new file mode 100644 index 00000000000..9d45417346b --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_10.txt @@ -0,0 +1,17 @@ +                                       +              _+***\++_                +             *'`+*+\~/_*,              +            ^_,||/~~-~+\,,             +           |__/\|;_.\,''\\,            +           / ;||"|^  /_/|/            +          |` '|*~//\   `_"|            +          \  ~*"*||~|*   |/,           +          "  ||\+/+||-_ .\||           +          "  ~\ \\|;~~+\+;||           +          |  ,|\,|_/_*___|*`           +           , "|||||""!\,"\|`           +           \`',\,*"  "",//            +            |' |||~*,:,/|/`            +             ;`**/|+;_!//'             +              *, _*\_,;*               +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_11.txt b/codex-rs/tui2/frames/default/frame_11.txt new file mode 100644 index 00000000000..769e5ae76d7 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_11.txt @@ -0,0 +1,17 @@ +                                       +               ,****++_                +              /" ;|||\\,               +             /"__||;\*/\,              +             |__||=;,_=//              +            _".;\|+\';_||,             +            |+`||+_;;|_/||             +            ** ,||||||_|=\             +            |  ||/||\/ |"|             +            /  '|/||*/+|_|             +            ** _|/=,"/|_|^             +            '`- ||||=/|\\|             +             \_-/|_*/**;|`             +             !_ *|\\^_|;"              +              \+!*||,_/*`              +               \_ '*+_+`               +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_12.txt b/codex-rs/tui2/frames/default/frame_12.txt new file mode 100644 index 00000000000..50cfd73302d --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_12.txt @@ -0,0 +1,17 @@ +                                       +                +***+.                 +               ,=`_/|,\                +               "  |/\+,                +               /+~||=="|               +              | '~|||./|               +              |'..*^"_|"               +              |   ~/\||\               +              |   /+\||"               +              *, ~/||+|~               +              |   /|*;*_               +              |.  |"=**/               +               ,  *|!_,|               +               / **|,*\|               +               '^/",|\|`               +                \ '~\./                +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_13.txt b/codex-rs/tui2/frames/default/frame_13.txt new file mode 100644 index 00000000000..04ed71335c1 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_13.txt @@ -0,0 +1,17 @@ +                                       +                 /***,                 +                 |__||                 +                 |`_|"                 +                 |**|_                 +                 *  ||                 +                 ":-||                 +                 ,  ||                 +                 +  "|                 +                /+  _~.                +                |"  +=|                +                '`.. ~`                +                 |___|                 +                 |+,|_                 +                 *__|=                 +                 , ."=                 +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_14.txt b/codex-rs/tui2/frames/default/frame_14.txt new file mode 100644 index 00000000000..66e91f7187b --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_14.txt @@ -0,0 +1,17 @@ +                                       +                 +***;                 +                 ,/__.\                +                |_||. |                +                ||/|"^~,               +                |||\   |               +                ~*||  '|               +                |||| . *               +                ||\|`  \               +                |||~   "               +                "^//  ;/               +                \|"",.,|               +                |*~|___|               +                /!"|===`               +                |\/*__/                +                 _* '=/                +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_15.txt b/codex-rs/tui2/frames/default/frame_15.txt new file mode 100644 index 00000000000..9d8132e3c41 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_15.txt @@ -0,0 +1,17 @@ +                                       +                 ++***~_               +                `,=||^:*,              +               //|*=\|"*|              +               |/` //,.__|             +              ||="=\||/"^|             +              \||-||//|  "             +              ||   ||||~~,|            +              ||/~|+||| '-|            +              ||+,,*|||_.:|            +              |_|;/|\~*. .|            +              |/||||_| ` ;             +              |**.^~|\-  =             +              '|\, ///` ;`             +               |^||\\.+\/              +                \^*^___/               +                   ``                  \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_16.txt b/codex-rs/tui2/frames/default/frame_16.txt new file mode 100644 index 00000000000..7217fe58b8e --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_16.txt @@ -0,0 +1,17 @@ +                                       +                _=+"**+~_              +               /^||*||\|=\             +              |//"\/=\|| '\            +             /// ;' \|\||**|           +             ||;_ =||*/|`\           +            |||*|  /|= !| ~.|          +            \\|  ,||||/*", |          +            |/; |`||/|||"; `|          +            \\|~|+~/^||"*+  /          +            *"__,==\*|._| ,_|          +            |||+""/*\|;";.~|`          +             ||* |   `//,  /           +             \|*  |  /,/_,|            +              \|~"_*~//+_|             +               ':._=:__;*              +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_17.txt b/codex-rs/tui2/frames/default/frame_17.txt new file mode 100644 index 00000000000..0d873df7518 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_17.txt @@ -0,0 +1,17 @@ +                                       +                ,=+++;;~,_             +             _;**|~~*=*|,"^,           +            ,*\/_==`+,"|||_"\          +           /|/_/|"   |;\~||=\         +           |/_ ~     "/\=\//  ,        +          `=*,/`   ,:/| /,=/|./        +          *!;/|   ,//|_ *"||/=|        +          -"=|!   !//||/ ,||=;*        +          ,/*/\==+~\_|\^:\||| |        +           |"_;__|/*\/||\!\+'+\        +          \\\/"""****\_|*//\ \'        +           \||_*       `/||` ;         +            _\\!*\_   ,',/^_/          +             , ~*+=\+`_;*:|'           +              `+;/_,+~*_+*             +                   `                   \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_18.txt b/codex-rs/tui2/frames/default/frame_18.txt new file mode 100644 index 00000000000..a474a4f3d03 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_18.txt @@ -0,0 +1,17 @@ +                                       +               _==+==+;~,_             +            _+"_;,++~__,"+;;_          +          _/:|*"*=" "._"+//\ *         +         ,||*.,^" _/=~\;\\\,       +        _\// /|   _\/~/|_\;\\_       +        /\| ,, _/,*,|'-/^/`/~!      +        ||\:/     +/*/|"_/"*|=|=,      +        "\-~|     ^\"||;^   |;|"       +       \"" ,\==;=;+~|,|*/\, |*|`       +        _\\|*\* ~|/__\_~||_;~`\ ,      +        |/|\`""*****` \__/|/*/`-`      +         \\!,\,         ``*"/*_'       +          \^*+^^.      _*^_/\,`        +           '|.**++===^'*_/_,*          +             "~|_/__,.+";+"            +                    `                  \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_19.txt b/codex-rs/tui2/frames/default/frame_19.txt new file mode 100644 index 00000000000..e83b78bd3ba --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_19.txt @@ -0,0 +1,17 @@ +                                       +              __==+~+==~._             +           _+|||\/===_*_,|*,,          +         ,*;;/"|+*`    `*:,`*;\        +        /;|*,^`         _==+,^|*,      +       ||/`/`         ,|^"/"|\ |\,     +      |\/*'         _|*~/!./ '.*|      +      ^=|~'        /*^/|+,`   /:/+|    +      ||||         |;"\|",    | |||    +      |*|\,=;;===~~~|+*;*|;   \ "||    +      ^;/,|=*/^*+\,`, ^_ :\_\,/.|_     +      '\"/+|"""""**"   ^\_/|// //'     +       \\^*_\             `//_//'      +        '!_\~~*,        ,;=./|;`       +          '".|*/~+__=;/*" ;*~'         +             "~._:-'_,.^*-^            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_2.txt b/codex-rs/tui2/frames/default/frame_2.txt new file mode 100644 index 00000000000..ac205dd4a51 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_2.txt @@ -0,0 +1,17 @@ +                                       +             _._:=+===+,_              +         _+,/*;+||~=~|=_'|*+_          +       ,|*|\**~*``  `"*=*/||;|,        +     _|/_/*',,_           -,\|/|,      +     ,^"/*^|\_\\_           ^,|\|,     +    '/+"`  |*\+/\+           \\|*|     +   ,|'*|    ^/|;|_|,          /"|"|    +   |" \`     ,|*||;*          |'/.|    +   *""=|    ;|^^_|".~++++++++,|_|~*    +    _='|  /||' /* |=\____..__|+/!|!    +    \\\ * *\..|'   `"*******"|,*||     +     '\_./, `              ,+;/,*      +       .\__|+,          ,=_+!_|"       +        `~^;__"*+:;=;;.=*`_||*         +           `*+*+~~____~;/*"            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_20.txt b/codex-rs/tui2/frames/default/frame_20.txt new file mode 100644 index 00000000000..bff8cc065f9 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_20.txt @@ -0,0 +1,17 @@ +                                       +              __+=~~=+=,__             +          ,;=";,_,===||_^*\+,          +        ,,"_+*"~*"    `"^"\+*|+_       +      _|"_*|*           _,+|\/*\\      +     _| ,|*           _/!_||^*\||\     +     /`,|'           +*';|"/  '\~|\    +    ;|;+|          ,* .+*^     ,\|/    +    |_^/`          "";:|",     |~"|    +    |/||;,=;======_,';^^\\*    / |\    +    '|^|_" /``____|\  *\\"|=\ ,/ ||    +     \,^\'"""""""*"     \,=||,| /\     +      * ^*/,               _/*.|/      +        \_^*,~_          ,;*`;*'       +          ^,-."~+^;;,:"~"`,/*'         +            `*+~_!=____|*""            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_21.txt b/codex-rs/tui2/frames/default/frame_21.txt new file mode 100644 index 00000000000..b23aadbc7c7 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_21.txt @@ -0,0 +1,17 @@ +                                       +             __,=+==+=+,__             +          ,+*``__+~=~+;_`-*+_          +        ;*^_+*^"`     `^~*+_`*,        +      ,*|;"'             _,;*,;*,      +     /,/*`             ,|/_\ \\_^,     +    /"/|             ,**_//    \\^,    +    |'|`           _!/`,/'     \;\"    +     `\            \; "|,       | |    +    | |  ,+;;;;;+++  ^|,"|,    ;/ |    +    | ~\ |_      _,|   ^"`*|,  /"./    +     ; ". ``"""  '`     '*_,; /` ,     +     '+ :;_                 _*" /      +       *_  ^-_          _.;*' ,`       +         *=_ *"*+:~~;:=*""  -`         +            *++;+____,_;+*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_22.txt b/codex-rs/tui2/frames/default/frame_22.txt new file mode 100644 index 00000000000..ccc8480d8b1 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_22.txt @@ -0,0 +1,17 @@ +                                       +             _,+=+==+=+,_              +         _+/*|._/|/\||;|+/*+_          +       ,;*;~\**`    `"*\**+__+,        +      ;*~**"            ,,|||_*\       +     \|~/'            ,_/|=|./;'|      +    \|*/`           ,~/|;^/` \|\=|     +   |+*\/           ~+|\*/'    ~|/|     +   ||=|`           |\|~^\     |^|"|    +   ||,", +~==;;;=++_\*|_!\,   _|;"!    +   ^|= *_\\,!/,"~//|  \*;_"|,,!/\=     +    \\\_| """""**"`    `:*+_///",`     +     \*\_\,               _*,"|,'      +      '"+; ++_         _.*,"*_/        +        ':*+,"*~;=+;;/=*""',*          +           "~"~_;___-=_.=*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_23.txt b/codex-rs/tui2/frames/default/frame_23.txt new file mode 100644 index 00000000000..406ced01b08 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_23.txt @@ -0,0 +1,17 @@ +                                       +            _,+_=+*++=+__              +         _=|+|\_,_==,|_|*|+_           +       ,"+|',;*`    "~:+|\;||,         +      /;|*;/`          _;;\||;\        +     //|`/'          ,/,,'*/*\|\       +    ,\|"/`         _***''/*'|~\|,      +    `||"|         /:/. _|`  "!|'*      +    ~/| |         |"|,_\,   | */|      +    ^"\ |+~;=====;=|_*|_"\_ | |~|      +    |\\_|"="       /`\ \|_\,~ \_|      +     /'| | `"""""""   '`/;=|_/^/`      +      ,||_|.             ,/|\^/`       +       \ |,'/__       _.";*/+/         +         \=\+;*+\~==_++"-_+*`          +           "~!*=\~__+__**`             +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_24.txt b/codex-rs/tui2/frames/default/frame_24.txt new file mode 100644 index 00000000000..73f56393902 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_24.txt @@ -0,0 +1,17 @@ +                                       +             _~_;++*==,_               +          ,"_/"|__~==+,_'+             +        ,"';\/+"~ .`:*~**, \           +       ,'//,/|=,\`~/`!_*\|\_'          +      , //\/,    `""/\_|``\|,'         +      ~,\+\`       *,,/!.|;!/"\        +     |  |~!      ,/_,/"/^\|\^|^        +     | \||       |,=/\_|~_/.`||        +     |. |;;;:++~~~++_*,_| |  ||        +      _ / !*|/,,;;,,|.^\+*| |*|        +      \ '\|\*""""""` \,.*\|=/,`        +       \ \,*\           |!"/;/         +        \.*\`|.      _="_/:_*          +          .-*,\^"~~:==;|^_/`           +            *:_*+;\__==+*              +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_25.txt b/codex-rs/tui2/frames/default/frame_25.txt new file mode 100644 index 00000000000..6fb0cbc16cf --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_25.txt @@ -0,0 +1,17 @@ +                                       +             _+=*;++++_                +           ,!*,*`_;\|*||,              +          /|//,,".|+\=\|||_            +         |+*| /! =| "\|,*\/            +        ,__\,/'^;/_|" |//\/*           +        \,~|~_*+.^|: /|,|//|,          +        |/|*| |__/\_/|\/_"|=|          +        |||/|."!~_=\/|\_~=||\          +       ^+\|"|__====+~|\\|+*|\          +        /-"|/|,|_||;*_|\*=/\|          +        \~*/\|`"""""'+/|\|/|`          +         |_"|;+;\-,*:_/,//|/           +          ^`^|_\_*;/,^/_||/            +           \.;\\_*=|**!*|*             +             ~,"*\+,_+|*               +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_26.txt b/codex-rs/tui2/frames/default/frame_26.txt new file mode 100644 index 00000000000..8bd6052839d --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_26.txt @@ -0,0 +1,17 @@ +                                       +              _;***"+,                 +             /*|;/\|+;|,               +            /*""|;\|\""\,              +           ;*",||~_||_+/^              +           |_,\|.~"*\/;|\;             +           \ "|/:/"*_\"\\/             +          |!  *'=||/||;;"+             +          /-  \|||^^=||/||             +          |  .\*;+~+==/\||             +          ! ._|/,|__,*\\*|             +           |`"/|*"\,/`+\||             +           \_~|/\   //"||`             +            *  *|\ ^`/|/,              +             |"*\\"*_**;               +              \/:^*~_\*                +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_27.txt b/codex-rs/tui2/frames/default/frame_27.txt new file mode 100644 index 00000000000..e8630695b8d --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_27.txt @@ -0,0 +1,17 @@ +                                       +                ;***+,                 +               |;:/|/\                 +              ;'` *|/'|                +              |~~^||;|~                +              |  `|_-'=                +              ~_._"`|||`               +              = "||_*||!               +             |  `||~="||               +             |. .!|+||||               +             '= ."_|_*||               +              |- _^**+/'               +              ||++||_/|                +              |==+=,/|^                +               \__|\=//                +               '\" ^\/                 +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_28.txt b/codex-rs/tui2/frames/default/frame_28.txt new file mode 100644 index 00000000000..3313d8b9bf7 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_28.txt @@ -0,0 +1,17 @@ +                                       +                 /**;                  +                 |+_|`                 +                 = ;|`                 +                 |-`*|                 +                 |  ~|                 +                 | -"|                 +                ^|~ _|                 +                 |-""|                 +                /\  _|                 +                ||.:~|                 +                 |  _|                 +                 |=+=|                 +                 |=*||                 +                 *__/|                 +                 ~ .+|                 +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_29.txt b/codex-rs/tui2/frames/default/frame_29.txt new file mode 100644 index 00000000000..2ae088f1b90 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_29.txt @@ -0,0 +1,17 @@ +                                       +                 +****,                +                ,|*/!*\                +                ^,|| '"|               +                ||||^\,/               +                \ ~"|  |               +                |,~|| __               +                |\\||| ^               +                ||=~|                  +                ||~*|   `              +                _|=||   `              +                *||/|^ =               +                |/~~| _|               +                ~|||`~_|               +                |=|*/"|'               +                 ~|* .,                +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_3.txt b/codex-rs/tui2/frames/default/frame_3.txt new file mode 100644 index 00000000000..727e25a8e89 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_3.txt @@ -0,0 +1,17 @@ +                                       +             _.=;++====,_              +         _+,/\||+|"==|;_^|*+_          +       ,;/*;|*""`   `"~!**+/^|,        +      /+/\|;,+_          `=*!||\       +     '/*|/^/||*\_           \^\||_     +    /|\\/  .,|\;\\           | \\|     +    =* |    '*\|_"|,          .*/|,    +   ,-|,|     ,-|"/\|          ~_|=|    +    -"'|    ;|*+~|*-~=++___++_~^";|    +    ,\:\, ./*/;;| |*|__,!=.,;\`|\|`    +    '^| | "||+,"   "***"""**,///,/     +     '*~ \+ `              /^+^//      +       =^//*\,          ,+;/",|'       +         =^+,_**^=;=;,:=*;`_+"         +           `*+*_:~____~;/^"            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_30.txt b/codex-rs/tui2/frames/default/frame_30.txt new file mode 100644 index 00000000000..99eeebce339 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_30.txt @@ -0,0 +1,17 @@ +                                       +                 _;"**+,               +               _/;||\*'=\              +               "'^|,\\|+,\             +              ||\|/|_\|\ \,            +              =*||`\|,|,  |            +             |*|^+  *||||.|            +             \/|||\_ \/\| =`           +             "| '+=~,|"|-  `           +             "|_"\~=~\/|,  `           +             "||\|__~|!+,  `           +             !\||;\_*~||+~|            +              |*//,/*\||" |            +              \|\||/*~|,~/             +               ,^,+=^/|,/'             +                \-.|^__;'              +                  ````                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_31.txt b/codex-rs/tui2/frames/default/frame_31.txt new file mode 100644 index 00000000000..8d9adf28b24 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_31.txt @@ -0,0 +1,17 @@ +                                       +                _.:*+*+=,              +              _+*;+,_"+\'*,            +             ,|\//,=_`."|_*\           +            ,~/+__*;_,\\|\~|\          +            ^||||+-*\_,"\|/__          +           ;|^`~_|'"\;*,./|,"|         +           /|| |^*|\.=/;*|*|/|         +           \\, |~"|\ |^""|~\.|         +           |\,_|'^~|~/+~~|_  |         +           "||~//||___\_|\|| *         +           ^*_/+|, /***`/|'~_!         +            |||\\\,     _=* |          +             ;|*=_!,  . |*`/`          +              :|\|/_|`,|",|`           +               '"=~_+*/.;*             +                   ````                \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_32.txt b/codex-rs/tui2/frames/default/frame_32.txt new file mode 100644 index 00000000000..4175a7a66ef --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_32.txt @@ -0,0 +1,17 @@ +                                       +                ,++++;;~,_             +              ;*~**~\||**|~,           +            /^*=^,+^:-`*|*\'*,         +           '//|_,`;- - ,_\|+,!,        +          //|,|*\'*|; '`,~\/\*\,       +          \/\\,\*|`:||+_   |+/ *       +         *|";,`'\||,,|,=`/_//|'_       +         |||,_  "_||"/'|;-_"\|""`      +         \||-_ '_|"__|+++~~~=|"=`      +         '""_`|*\'\_____||*|;~-_       +           ,\*||/ |*""***^;///./       +           \,|^\\        ,\|/'~        +           '**||~+_    _/_|/ ,         +             \-\"~+|;=*`_+"_/          +               ~;=__,+/*_""            +                   ```                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_33.txt b/codex-rs/tui2/frames/default/frame_33.txt new file mode 100644 index 00000000000..dbd9568018a --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_33.txt @@ -0,0 +1,17 @@ +                                       +               _,+=+=+=~._             +            _+*||\*=~:|-|*|*.          +          _/\|+*+,="`  "*/||+",        +         ,\/|/_|,_        *||\_*       +        _.|/`||/\^\         |+\\^      +        //,   \_\\`\,        /\/_\     +        ||+ !  \,*|_|\       |*||      +        *|,,   ,''\/=,       \"|*      +        ||| | ,` /*,,,;==+~~+/_|\~     +        ^/|'"/:,/`~|_____||*^^^~|.     +        \=\, |/|/ / """"*** ||,/^`     +         \+\*,",           //;/=/      +          *\"*,*;,      _+|,/*\'       +            \~"*\+|,;+_";,\*~*         +              "==+,___^+*;-"           +                   ````                \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_34.txt b/codex-rs/tui2/frames/default/frame_34.txt new file mode 100644 index 00000000000..7fc67a92dbc --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_34.txt @@ -0,0 +1,17 @@ +                                       +               _,=++++=~,_             +           _+*+/\;|"~+*=**;,=_         +         _//,|*":~*`   `^\\,"**,       +        ,/_|*_/,_          *|,*\\      +       //|\-/|!|"!,          \\*\^     +      ,/\|`/ \\"|\*\          \,\|\    +      |*^~_   '\_*|_^          |;~_    +      \|=":    |*_|/"|         / _\    +      ,'/."   /\//"_==++++++~__" ~^`   +      ||\,/,,^"//'~||_~.___,/_||`|\    +       /_\, |\|*^!  "**````^ ,/,\|'    +        /,\;=\_             /;.|/'     +         \+`|+\;,        _+="_/,`      +           -\/~"|+,;,+=*=*`+*:'        +             "~\;=_____;=*=^           +                   ```                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_35.txt b/codex-rs/tui2/frames/default/frame_35.txt new file mode 100644 index 00000000000..570f34f0de5 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_35.txt @@ -0,0 +1,17 @@ +                                       +              _,+;=+++=+,_             +           ,*=*.~**+"|*~:"~*=_         +        _//|;+*|^*"`  `"*=*."|*,       +       ,//+/,;,_            *+_\|_     +      //~|//\ "\*,            |\*|,    +     ;/;/+` '|_"..*_           |\\|    +     \ !./    \\ '*_.           |^\\   +     ~|~:      /\\;/'           / "/   +     / ,_    ,||/;"/|==_;_=+~~  |~=*   +     |,^;'  //;"*+/|; \,____/"~/' |'   +      /`\,'/\_|/^`  `"^^^^*^^*//_,|    +       ".^\/~               _/* ,^     +        \;`^;,:,          ,;/_**'      +          "^_"~*~-:~~+~;/*"_/;"        +             "^~;=__/__++*"^           +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_36.txt b/codex-rs/tui2/frames/default/frame_36.txt new file mode 100644 index 00000000000..74d83c8e702 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_36.txt @@ -0,0 +1,17 @@ +                                       +              __+=++++=+,_             +          _=""\+/;/+\+;++"**+_         +        ,\'\,+*-*"`` `"*~*+|,*|,       +      _|"*+____            '*~\"|      +     ,/_;\'|\`\,.             ^\.*     +     / ,/`  *_ "|/,            "\^*    +    | ;!`     !\ "\\            |^|,   +    ||\~      _\ _//!           \| |   +    |'"|     // ,*"',++_+++++_  |\~|   +     _*|\  ,|__/~/ !`~_______|| \/'`   +     ' *|\ +_+/^     "**^^^^^" |,"/    +      ',"\;.                 ,/|"/     +        \/||+~,           ,++"/,`      +          *,_"**=^;~_+~;"-",;+'        +            `*+/~_,,_,,++**"           +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_4.txt b/codex-rs/tui2/frames/default/frame_4.txt new file mode 100644 index 00000000000..06dbce99c07 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_4.txt @@ -0,0 +1,17 @@ +                                       +             _.=;+==+=+,_              +         _-"+*|/!|\=/*;|"/*,_          +       ,*=|||+*"`   `^~\^*|/\\_        +      //|,|".+,          "|**\*\       +     /|/_|=|\;^|,          \"\|*\      +    /||^|  '_||/*\          ' \=|,     +    "\|;`   '**\+"|,         , ";|     +     |.\     ,|/*^||           |||     +    _|!|_   ;|/"^//+~+++____,, ||*     +    |\/!| ,///,_|`=\|,._,:/^;|//"|     +     `|;'\,\\,\/   "*******'^-|||`     +      ,|\"|_`             ,^;/**'      +       \\,:^!,_        _.^,*;|/        +         ~||=_**\;;=;,+=*+\|*`         +            *\\~:~_____;-*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_5.txt b/codex-rs/tui2/frames/default/frame_5.txt new file mode 100644 index 00000000000..6b1ce124479 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_5.txt @@ -0,0 +1,17 @@ +                                       +             _.=;;+===+,_              +         _+"+/*/||+~=+;_|"+,           +        *_/\+|*"`   "^=|*\!,*,         +      ,|/|/|.=,          -^||||_       +     ,\/*/^_+|_\,         ` \|\|_      +     ~|||/ \_|\;/|_          +/||      +    |/|!+   `\_|\"|,        ''~+|,     +    |/|_"    ,_=|/_|          +|_|     +    |,|^*   /_|",|*=_~+~___+,_"|.|     +     _\|\,,/+*" |"!//\=^,=.,/|/*|`     +     \,||,~,\\/+`  "**""""",~;|\/      +      \\+/\\ `            /_/*,^       +       ^/*\|=+_        _=+,*';*        +         ^|*|,*+\;~=_,+=*^~;*          +            ^-*_*"___-_,+*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_6.txt b/codex-rs/tui2/frames/default/frame_6.txt new file mode 100644 index 00000000000..7724f483dc6 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_6.txt @@ -0,0 +1,17 @@ +                                       +             _.=;;+==++__              +          ,^;/*|=*+|++;_,*+_           +        ,,,|||*"   `"~.=*+*/\_         +       /,|*|*_=,        \+||||,        +      '=|||*\\+*\         .\\|\,       +     ;-/|/`^+;|\_|,        .=\/|       +     _ +~|   !^\|/^\        ^+|_|      +     ~ |~|   _|=~/||        ~+\||      +     _.|-|  /'||*;/+_~+~__++_.\/|      +     ||\,/_^_;+ |/=*,||,;==\|,|\!      +      / | /~||;/"  "*"""'*"`\\/|       +       , ; /,`           ,:_//|`       +        .`\_**,       _+^_/*,+         +         `"~*,"+!;~__,+**!:;'          +            ^-;*+,___`_,+*             +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_7.txt b/codex-rs/tui2/frames/default/frame_7.txt new file mode 100644 index 00000000000..0d0f43072c6 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_7.txt @@ -0,0 +1,17 @@ +                                       +             _.~;*+==+,_               +          ,*`+\|+*+==\;!`*,            +         * ,|||*`  `^~`!*/_*,          +        \\|||~;+       ^~\*|/,         +       `'|*||^|/\,       . *||\        +      | ;||" `'\;|\       ,-|||        +       `\"|  ^_|\|\|       **^||       +      _"/~|   =+/|*|`      ' |||       +       /"~* ,"_/|\/~~;;_~~=;*\||       +      |,'||=:~|/'|.\||\\,=,;\|\|       +       \ =^/*|*_/  ******""_/||        +       '_ /_/_`         ,"-/;/'        +        ';,+\\\_      ,^~,*/*'         +          \_'\|*\;~___+*|*+/           +            "~*"~:~__,_;**             +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_8.txt b/codex-rs/tui2/frames/default/frame_8.txt new file mode 100644 index 00000000000..2e8019c0612 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_8.txt @@ -0,0 +1,17 @@ +                                       +             __+_;++=+,_               +           ,"*/|||*==!~ "+             +         _|=,|//*!,"~/~*\+^,           +        _*\*/|,+|     ' =^||\          +        ' /|/,\|/\      .'\||,         +       |',|\^^_\|_*      \+|||         +       |*||| '_;\|`|      ^|/|,        +       \,||/  |+\/|,*.    .`/||        +       \ \||_^ !/*=|~/+,+,,\~||        +       !  \*/=_|",|,||;|=__||='        +        //\\|\|||/'^*^*"\*"/\|         +        ',"|\|``        .,///'         +         '\_*\\\_    _/\_|+/*          +           .:'|,*!;;+*;/=,|`           +             ~~_**\|_,+/=`             +                  ``                   \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_9.txt b/codex-rs/tui2/frames/default/frame_9.txt new file mode 100644 index 00000000000..128e9150078 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_9.txt @@ -0,0 +1,17 @@ +                                       +              .=^*/++=,                +            /*_/||*"=!_-\_             +           / ,||/*^^=/!\_~,            +          " ||/;=_ _^,|\^|+,           +         /*";\*"|*,  +:+||           +         | |||"^|\;*   '|*|||          +         ` ~*\ **|\\," / `||          +        |  ~/_ ~||_/= | !||          +        !  ",|" /|"|~~|+|~,||`         +         |_|||_,|^|_||||__|||          +         " `^/\\|/"****||"/\|          +          \~||\,`     ,/ ^/|`          +           \+\|\\    /;_;|/*           +            ^-"\_|*/+=;;*|`            +             '-_ *\\|;+/"              +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_1.txt b/codex-rs/tui2/frames/dots/frame_1.txt new file mode 100644 index 00000000000..36964a48647 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_1.txt @@ -0,0 +1,17 @@ + + ○◉○◉○●●○○●●○ + ○○●◉●○●◉●○○··○○ ○ ●○ + ●·●·●●● ○· · ●· ·○···● + ◉●○○●●●●○ ◉●·◉·● + ○○◉◉●○·○·○○ ◉·○○● + ·● ●· ·●◉◉··● ●◉○··● + ●○ ◉● ●○·●◉ ·○ ·◉· + ··○·· ●◉◉·◉·● ·· + ·○·●· ◉··○○· ◉·●●●●●●○●● ○ · + ○·◉● ○◉●· ◉● · ·○○○◉◉●●●·◉●◉●· + ○○○ ○ ○○●◉◉· ·○●●●● ○● ◉●◉○◉ + ●○● ● · ●●◉●○· + ○○●·○●○ ○○○●·○● + ○●●○○ ●●◉◉○◉◉◉○●●○●·● + ·● ●···○○○●○◉●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_10.txt b/codex-rs/tui2/frames/dots/frame_10.txt new file mode 100644 index 00000000000..3c687d7f64f --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_10.txt @@ -0,0 +1,17 @@ + + ○●●●●○●●○ + ●●·●●●○·◉○●● + ○○●··◉··◉·●○●● + ·○○◉○·◉○◉○●●●○○● + ◉ ◉·· ·○ ●●◉○◉·◉ + ·· ●·●·◉◉○ ·○ · + ○ ·● ●····● ·◉● + ··○●◉●··◉○·◉○·· + ·○ ○○·◉··●○●◉·· + · ●·○●·○◉○●○○○·●· + ● ····· ○● ○·· + ○·●●○●● ○· ●◉◉ + ·● ····●●◉●◉·◉· + ◉·●●◉·●◉○ ◉◉● + ●● ○●○○●◉● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_11.txt b/codex-rs/tui2/frames/dots/frame_11.txt new file mode 100644 index 00000000000..c2548db4b3c --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_11.txt @@ -0,0 +1,17 @@ + + ●●●●●●●○ + ◉ ◉···○○● + ◉ ○○··◉○●◉○● + ·○○··○◉●○○◉◉ + ○ ◉◉○·●○●◉○··● + ·●···●○◉◉·○◉·· + ●● ●······○·○○ + · ··◉··○◉ · · + ◉ ●·◉··●◉●·○· + ●● ○·◉○● ◉·○·○ + ●·◉ ····○◉·○○· + ○○◉◉·○●◉●●◉·· + ○ ●·○○○○·◉ + ○● ●··●○◉●· + ○○ ●●●○●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_12.txt b/codex-rs/tui2/frames/dots/frame_12.txt new file mode 100644 index 00000000000..30b03392bf4 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_12.txt @@ -0,0 +1,17 @@ + + ●●●●●◉ + ●○·○◉·●○ + ·◉○●● + ◉●···○○ · + · ●····◉◉· + ·●◉◉●○ ○· + · ·◉○··○ + · ◉●○·· + ●● ·◉··●·· + · ◉·●◉●○ + ·◉ · ○●●◉ + ● ●· ○●· + ◉ ●●·●●○· + ●○◉ ●·○·· + ○ ●·○◉◉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_13.txt b/codex-rs/tui2/frames/dots/frame_13.txt new file mode 100644 index 00000000000..cb95f3763d3 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_13.txt @@ -0,0 +1,17 @@ + + ◉●●●● + ·○○·· + ··○· + ·●●·○ + ● ·· + ◉◉·· + ● ·· + ● · + ◉● ○·◉ + · ●○· + ●·◉◉ ·· + ·○○○· + ·●●·○ + ●○○·○ + ● ◉ ○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_14.txt b/codex-rs/tui2/frames/dots/frame_14.txt new file mode 100644 index 00000000000..3a8ed60b8ff --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_14.txt @@ -0,0 +1,17 @@ + + ●●●●◉ + ●◉○○◉○ + ·○··◉ · + ··◉· ○·● + ···○ · + ·●·· ●· + ···· ◉ ● + ··○·· ○ + ···· + ○◉◉ ◉◉ + ○· ●◉●· + ·●··○○○· + ◉ ·○○○· + ·○◉●○○◉ + ○● ●○◉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_15.txt b/codex-rs/tui2/frames/dots/frame_15.txt new file mode 100644 index 00000000000..c57b4af0ee5 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_15.txt @@ -0,0 +1,17 @@ + + ●●●●●·○ + ·●○··○◉●● + ◉◉·●○○· ●· + ·◉· ◉◉●◉○○· + ··○ ○○··◉ ○· + ○··◉··◉◉· + ·· ······●· + ··◉··●··· ●◉· + ··●●●●···○◉◉· + ·○·◉◉·○·●◉ ◉· + ·◉····○· · ◉ + ·●●◉○··○◉ ○ + ●·○● ◉◉◉· ◉· + ·○··○○◉●○◉ + ○○●○○○○◉ + ·· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_16.txt b/codex-rs/tui2/frames/dots/frame_16.txt new file mode 100644 index 00000000000..18ae0e09ee3 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_16.txt @@ -0,0 +1,17 @@ + + ○○● ●●●·○ + ◉○··●··○·○○ + ·◉◉ ○◉○○·· ●○ + ◉◉◉ ◉●○○·○··●●· + ··◉○◉●●○··●◉··○ + ···●·● ·○○ · ·◉· + ○○· ◉·●····◉● ● · + ·◉◉ ····◉··· ◉ ·· + ○○···●·◉○·· ●● ◉ + ● ○○●○○○●·◉○· ●○· + ···● ◉●○·◉ ◉◉··· + ··● ·· ·◉◉● ◉ + ○·● · ◉●◉○●· + ○·· ○●·◉◉●○· + ●◉◉○○◉○○◉● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_17.txt b/codex-rs/tui2/frames/dots/frame_17.txt new file mode 100644 index 00000000000..a470b4ba8df --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_17.txt @@ -0,0 +1,17 @@ + + ●○●●●◉◉·●○ + ○◉●●···●○●·● ○● + ●●○◉○○○·●● ···○ ○ + ◉·◉○◉· ○· ●·◉○···○○ + ·◉○ · · ◉○○○◉◉ ● + ·○●●◉· ●◉◉· ◉●○◉·◉◉ + ● ◉◉· ●◉◉·○ ● ··◉○· + ◉ ○· ◉◉··◉·●··○◉● + ●◉●◉○○○●·○○·○○◉○··· · + · ○◉○○·◉●○◉··○ ○●●●○ + ○○○◉ ●●●●○○·●◉◉○ ○● + ○··○● ·◉··· ◉ + ○○○ ●○○ ●●●◉○○◉ + ● ·●●○○●·○◉●◉·● + ·●◉◉○●●·●○●● + · \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_18.txt b/codex-rs/tui2/frames/dots/frame_18.txt new file mode 100644 index 00000000000..c0354b39331 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_18.txt @@ -0,0 +1,17 @@ + + ○○○●○○●◉·●○ + ○● ○◉●●●·○○● ●◉◉○ + ○◉◉·● ●○ ● ◉○ ●◉◉○ ● + ●··●◉●○ ● ●○·○◉○·○◉○○○● + ○○◉◉ ◉· ○ ●○○◉·◉·○○◉○○○ + ◉○· ●●·◉○○○◉●●●·●◉◉○◉·◉· + ··○◉◉ ●◉●◉· ○◉ ●·○·○● + ○◉·· ○○ ··◉○ ·◉· + ○ ●○○○◉○◉●··●·●◉○● ·●·· + ○○○·●○● ··◉○○○○···○◉··○ ● + ·◉·○· ●●●●●· ○○○◉·◉●◉·◉· + ○○ ●○● ··● ◉●○● + ○○●●○○◉ ○●○○◉○●· + ●·◉●●●●○○○○●●○◉○●● + ··○◉○○●◉● ◉● + · \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_19.txt b/codex-rs/tui2/frames/dots/frame_19.txt new file mode 100644 index 00000000000..c9ded568388 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_19.txt @@ -0,0 +1,17 @@ + + ○○○○●·●○○·◉○ + ○●···○◉○○○○●○●·●●● + ●●◉◉◉ ·●●· ·●◉●·●◉○ + ◉◉·●●○· ○○○●●○·●● + ··◉·◉· ●·○ ◉ ·○ ·○● + ·○◉●● ○·●·◉ ◉◉ ●◉●· + ○○··● ◉●○◉·●●· ◉◉◉●· + ···· ·◉ ○· ● · ··· + ·●·○●○◉◉○○○····●●◉●·◉ ○ ·· + ○◉◉●·○●◉○●●○●·● ○○ ◉○○○●◉◉·○ + ●○ ◉●· ●● ○○○◉·◉◉ ◉◉● + ○○○●○○ ·◉◉○◉◉● + ● ○○··●● ●◉○◉◉·◉· + ● ◉·●◉·●○○○◉◉● ◉●·● + ·◉○◉◉●○●◉○●◉○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_2.txt b/codex-rs/tui2/frames/dots/frame_2.txt new file mode 100644 index 00000000000..6e7a27fb294 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_2.txt @@ -0,0 +1,17 @@ + + ○◉○◉○●○○○●●○ + ○●●◉●◉●···○··○○●·●●○ + ●·●·○●●·●·· · ●○●◉··◉·● + ○·◉○◉●●●●○ ◉●○·◉·● + ●○ ◉●○·○○○○○ ○●·○·● + ●◉● · ·●○●◉○● ○○·●· + ●·●●· ○◉·◉·○·● ◉ · · + · ○· ●·●··◉● ·●◉◉· + ● ○· ◉·○○○· ◉·●●●●●●●●●·○··● + ○○●· ◉··● ◉● ·○○○○○○◉◉○○·●◉ · + ○○○ ● ●○◉◉·● · ●●●●●●● ·●●·· + ●○○◉◉● · ●●◉◉●● + ◉○○○·●● ●○○● ○· + ··○◉○○ ●●◉◉○◉◉◉○●·○··● + ·●●●●··○○○○·◉◉● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_20.txt b/codex-rs/tui2/frames/dots/frame_20.txt new file mode 100644 index 00000000000..d9809e733cc --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_20.txt @@ -0,0 +1,17 @@ + + ○○●○··○●○●○○ + ●◉○ ◉●○●○○○··○○●○●● + ●● ○●● ·● · ○ ○●●·●○ + ○· ○●·● ○●●·○◉●○○ + ○· ●·● ○◉ ○··○●○··○ + ◉·●·● ●●●◉· ◉ ●○··○ + ◉·◉●· ●● ◉●●○ ●○·◉ + ·○○◉· ◉◉· ● ·· · + ·◉··◉●○◉○○○○○○○●●◉○○○○● ◉ ·○ + ●·○·○ ◉··○○○○·○ ●○○ ·○○ ●◉ ·· + ○●○○● ● ○●○··●· ◉○ + ● ○●◉● ○◉●◉·◉ + ○○○●●·○ ●◉●·◉●● + ○●◉◉ ·●○◉◉●◉ · ·●◉●● + ·●●·○ ○○○○○·● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_21.txt b/codex-rs/tui2/frames/dots/frame_21.txt new file mode 100644 index 00000000000..0821f12d752 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_21.txt @@ -0,0 +1,17 @@ + + ○○●○●○○●○●●○○ + ●●●··○○●·○·●◉○·◉●●○ + ◉●○○●●○ · ·○·●●○·●● + ●●·◉ ● ○●◉●●◉●● + ◉●◉●· ●·◉○○ ○○○○● + ◉ ◉· ●●●○◉◉ ○○○● + ·●·· ○ ◉·●◉● ○◉○ + ·○ ○◉ ·● · · + · · ●●◉◉◉◉◉●●● ○·● ·● ◉◉ · + · ·○ ·○ ○●· ○ ·●·● ◉ ◉◉ + ◉ ◉ ·· ●· ●●○●◉ ◉· ● + ●● ◉◉○ ○● ◉ + ●○ ○◉○ ○◉◉●● ●· + ●○○ ● ●●◉··◉◉○● ◉· + ●●●◉●○○○○●○◉●●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_22.txt b/codex-rs/tui2/frames/dots/frame_22.txt new file mode 100644 index 00000000000..d6733498019 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_22.txt @@ -0,0 +1,17 @@ + + ○●●○●○○●○●●○ + ○●◉●·◉○◉·◉○··◉·●◉●●○ + ●◉●◉·○●●· · ●○●●●○○●● + ◉●·●● ●●···○●○ + ○··◉● ●○◉·○·◉◉◉●· + ○·●◉· ●·◉·◉○◉· ○·○○· + ·●●○◉ ·●·○●◉● ··◉· + ··○·· ·○··○○ ·○· · + ··● ● ●·○○◉◉◉○●●○○●·○ ○● ○·◉ + ○·○ ●○○○● ◉● ·◉◉· ○●◉○ ·●● ◉○○ + ○○○○· ●● · ·◉●●○◉◉◉ ●· + ○●○○○● ○●● ·●● + ● ●◉ ●●○ ○◉●● ●○◉ + ●◉●●● ●·◉○●◉◉◉○● ●●● + · ·○◉○○○◉○○◉○●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_23.txt b/codex-rs/tui2/frames/dots/frame_23.txt new file mode 100644 index 00000000000..180ab167842 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_23.txt @@ -0,0 +1,17 @@ + + ○●●○○●●●●○●○○ + ○○·●·○○●○○○●·○·●·●○ + ● ●·●●◉●· ·◉●·○◉··● + ◉◉·●◉◉· ○◉◉○··◉○ + ◉◉··◉● ●◉●●●●◉●○·○ + ●○· ◉· ○●●●●●◉●●··○·● + ··· · ◉◉◉◉ ○·· ·●● + ·◉· · · ·●○○● · ●◉· + ○ ○ ·●·◉○○○○○◉○·○●·○ ○○ · ··· + ·○○○· ○ ◉·○ ○·○○●· ○○· + ◉●· · · ●·◉◉○·○◉○◉· + ●··○·◉ ●◉·○○◉· + ○ ·●●◉○○ ○◉ ◉●◉●◉ + ○○○●◉●●○·○○○●● ◉○●●· + · ●○○·○○●○○●●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_24.txt b/codex-rs/tui2/frames/dots/frame_24.txt new file mode 100644 index 00000000000..3244b1c6f92 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_24.txt @@ -0,0 +1,17 @@ + + ○·○◉●●●○○●○ + ● ○◉ ·○○·○○●●○●● + ● ●◉○◉● · ◉·◉●·●●● ○ + ●●◉◉●◉·○●○··◉· ○●○·○○● + ● ◉◉○◉● · ◉○○···○·●● + ·●○●○· ●●●◉ ◉·◉ ◉ ○ + · ·· ●◉○●◉ ◉○○·○○·○ + · ○·· ·●○◉○○··○◉◉··· + ·◉ ·◉◉◉◉●●···●●○●●○· · ·· + ○ ◉ ●·◉●●◉◉●●·◉○○●●· ·●· + ○ ●○·○● · ○●◉●○·○◉●· + ○ ○●●○ · ◉◉◉ + ○◉●○··◉ ○○ ○◉◉○● + ◉◉●●○○ ··◉○○◉·○○◉· + ●◉○●●◉○○○○○●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_25.txt b/codex-rs/tui2/frames/dots/frame_25.txt new file mode 100644 index 00000000000..c04ef18b74f --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_25.txt @@ -0,0 +1,17 @@ + + ○●○●◉●●●●○ + ● ●●●·○◉○·●··● + ◉·◉◉●● ◉·●○○○···○ + ·●●· ◉ ○· ○·●●○◉ + ●○○○●◉●○◉◉○· ·◉◉○◉● + ○●···○●●◉○·◉ ◉·●·◉◉·● + ·◉·●· ·○○◉○○◉·○◉○ ·○· + ···◉·◉ ·○○○◉·○○·○··○ + ○●○· ·○○○○○○●··○○·●●·○ + ◉◉ ·◉·●·○··◉●○·○●○◉○· + ○·●◉○·· ●●◉·○·◉·· + ·○ ·◉●◉○◉●●◉○◉●◉◉·◉ + ○·○·○○○●◉◉●○◉○··◉ + ○◉◉○○○●○·●● ●·● + ·● ●○●●○●·● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_26.txt b/codex-rs/tui2/frames/dots/frame_26.txt new file mode 100644 index 00000000000..1ecc43beef2 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_26.txt @@ -0,0 +1,17 @@ + + ○◉●●● ●● + ◉●·◉◉○·●◉·● + ◉● ·◉○·○ ○● + ◉● ●···○··○●◉○ + ·○●○·◉· ●○◉◉·○◉ + ○ ·◉◉◉ ●○○ ○○◉ + · ●●○··◉··◉◉ ● + ◉◉ ○···○○○··◉·· + · ◉○●◉●·●○○◉○·· + ◉○·◉●·○○●●○○●· + ·· ◉·● ○●◉·●○·· + ○○··◉○ ◉◉ ··· + ● ●·○ ○·◉·◉● + · ●○○ ●○●●◉ + ○◉◉○●·○○● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_27.txt b/codex-rs/tui2/frames/dots/frame_27.txt new file mode 100644 index 00000000000..83e62da52e2 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_27.txt @@ -0,0 +1,17 @@ + + ◉●●●●● + ·◉◉◉·◉○ + ◉●· ●·◉●· + ···○··◉·· + · ··○◉●○ + ·○◉○ ····· + ○ ··○●·· + · ····○ ·· + ·◉ ◉ ·●···· + ●○ ◉ ○·○●·· + ·◉ ○○●●●◉● + ··●●··○◉· + ·○○●○●◉·○ + ○○○·○○◉◉ + ●○ ○○◉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_28.txt b/codex-rs/tui2/frames/dots/frame_28.txt new file mode 100644 index 00000000000..6d460c936de --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_28.txt @@ -0,0 +1,17 @@ + + ◉●●◉ + ·●○·· + ○ ◉·· + ·◉·●· + · ·· + · ◉ · + ○·· ○· + ·◉ · + ◉○ ○· + ··◉◉·· + · ○· + ·○●○· + ·○●·· + ●○○◉· + · ◉●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_29.txt b/codex-rs/tui2/frames/dots/frame_29.txt new file mode 100644 index 00000000000..d0d6b3c286d --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_29.txt @@ -0,0 +1,17 @@ + + ●●●●●● + ●·●◉ ●○ + ○●·· ● · + ····○○●◉ + ○ · · · + ·●··· ○○ + ·○○··· ○ + ··○·· + ···●· · + ○·○·· · + ●··◉·○ ○ + ·◉··· ○· + ······○· + ·○·●◉ ·● + ··● ◉● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_3.txt b/codex-rs/tui2/frames/dots/frame_3.txt new file mode 100644 index 00000000000..062da3ed89f --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_3.txt @@ -0,0 +1,17 @@ + + ○◉○◉●●○○○○●○ + ○●●◉○··●· ○○·◉○○·●●○ + ●◉◉●◉·● · · · ●●●◉○·● + ◉●◉○·◉●●○ ·○● ··○ + ●◉●·◉○◉··●○○ ○○○··○ + ◉·○○◉ ◉●·○◉○○ · ○○· + ○● · ●●○·○ ·● ◉●◉·● + ●◉·●· ●◉· ◉○· ·○·○· + ◉ ●· ◉·●●··●◉·○●●○○○●●○·○ ◉· + ●○◉○● ◉◉●◉◉◉· ·●·○○● ○◉●◉○··○·· + ●○· · ··●● ●●● ●●●◉◉◉●◉ + ●●· ○● · ◉○●○◉◉ + ○○◉◉●○● ●●◉◉ ●·● + ○○●●○●●○○◉○◉●◉○●◉·○● + ·●●●○◉·○○○○·◉◉○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_30.txt b/codex-rs/tui2/frames/dots/frame_30.txt new file mode 100644 index 00000000000..4bf02ade3d8 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_30.txt @@ -0,0 +1,17 @@ + + ○◉ ●●●● + ○◉◉··○●●○○ + ●○·●○○·●●○ + ··○·◉·○○·○ ○● + ○●···○·●·● · + ·●·○● ●····◉· + ○◉···○○ ○◉○· ○· + · ●●○·●· ·◉ · + ·○ ○·○·○◉·● · + ··○·○○·· ●● · + ○··◉○○●···●·· + ·●◉◉●◉●○·· · + ○·○··◉●··●·◉ + ●○●●○○◉·●◉● + ○◉◉·○○○◉● + ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_31.txt b/codex-rs/tui2/frames/dots/frame_31.txt new file mode 100644 index 00000000000..99385ee51fa --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_31.txt @@ -0,0 +1,17 @@ + + ○◉◉●●●●○● + ○●●◉●●○ ●○●●● + ●·○◉◉●○○·◉ ·○●○ + ●·◉●○○●◉○●○○·○··○ + ○····●◉●○○● ○·◉○○ + ◉·○··○·● ○◉●●◉◉·● · + ◉·· ·○●·○◉○◉◉●·●·◉· + ○○● ·· ·○ ·○ ··○◉· + ·○●○·●○···◉●···○ · + ···◉◉··○○○○○·○·· ● + ○●○◉●·● ◉●●●·◉·●·○ + ···○○○● ○○● · + ◉·●○○ ● ◉ ·●·◉· + ◉·○·◉○··●· ●·· + ● ○·○●●◉◉◉● + ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_32.txt b/codex-rs/tui2/frames/dots/frame_32.txt new file mode 100644 index 00000000000..771e9c9106b --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_32.txt @@ -0,0 +1,17 @@ + + ●●●●●◉◉·●○ + ◉●·●●·○··●●··● + ◉○●○○●●○◉◉·●·●○●●● + ●◉◉·○●·◉◉ ◉ ●○○·●● ● + ◉◉·●·●○●●·◉ ●·●·○◉○●○● + ○◉○○●○●··◉··●○ ·●◉ ● + ●· ◉●·●○··●●·●○·◉○◉◉·●○ + ···●○ ○·· ◉●·◉◉○ ○· · + ○··◉○ ●○· ○○·●●●···○· ○· + ● ○··●○●○○○○○○··●·◉·◉○ + ●○●··◉ ·● ●●●○◉◉◉◉◉◉ + ○●·○○○ ●○·◉●· + ●●●···●○ ○◉○·◉ ● + ○◉○ ·●·◉○●·○● ○◉ + ·◉○○○●●◉●○ + ··· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_33.txt b/codex-rs/tui2/frames/dots/frame_33.txt new file mode 100644 index 00000000000..4d36c1eb6f2 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_33.txt @@ -0,0 +1,17 @@ + + ○●●○●○●○·◉○ + ○●●··○●○·◉·◉·●·●◉ + ○◉○·●●●●○ · ●◉··● ● + ●○◉·◉○·●○ ●··○○● + ○◉·◉···◉○○○ ·●○○○ + ◉◉● ○○○○·○● ◉○◉○○ + ··● ○●●·○·○ ·●·· + ●·●● ●●●○◉○● ○ ·● + ··· · ●· ◉●●●●◉○○●··●◉○·○· + ○◉·● ◉◉●◉···○○○○○··●○○○··◉ + ○○○● ·◉·◉ ◉ ●●● ··●◉○· + ○●○●● ● ◉◉◉◉○◉ + ●○ ●●●◉● ○●·●◉●○● + ○· ●○●·●◉●○ ◉●○●·● + ○○●●○○○○●●◉◉ + ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_34.txt b/codex-rs/tui2/frames/dots/frame_34.txt new file mode 100644 index 00000000000..4cbd99c1435 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_34.txt @@ -0,0 +1,17 @@ + + ○●○●●●●○·●○ + ○●●●◉○◉· ·●●○●●◉●○○ + ○◉◉●·● ◉·●· ·○○○● ●●● + ●◉○·●○◉●○ ●·●●○○ + ◉◉·○◉◉· · ● ○○●○○ + ●◉○··◉ ○○ ·○●○ ○●○·○ + ·●○·○ ●○○●·○○ ·◉·○ + ○·○ ◉ ·●○·◉ · ◉ ○○ + ●●◉◉ ◉○◉◉ ○○○●●●●●●·○○ ·○· + ··○●◉●●○ ◉◉●···○·◉○○○●◉○····○ + ◉○○● ·○·●○ ●●····○ ●◉●○·● + ◉●○◉○○○ ◉◉◉·◉● + ○●··●○◉● ○●○ ○◉●· + ◉○◉· ·●●◉●●○●○●·●●◉● + ·○◉○○○○○○◉○●○○ + ··· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_35.txt b/codex-rs/tui2/frames/dots/frame_35.txt new file mode 100644 index 00000000000..5ccdf711b5b --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_35.txt @@ -0,0 +1,17 @@ + + ○●●◉○●●●○●●○ + ●●○●◉·●●● ·●·◉ ·●○○ + ○◉◉·◉●●·○● · · ●○●◉ ·●● + ●◉◉●◉●◉●○ ●●○○·○ + ◉◉··◉◉○ ○●● ·○●·● + ◉◉◉◉●· ●·○ ◉◉●○ ·○○· + ○ ◉◉ ○○ ●●○◉ ·○○○ + ···◉ ◉○○◉◉● ◉ ◉ + ◉ ●○ ●··◉◉ ◉·○○○◉○○●·· ··○● + ·●○◉● ◉◉◉ ●●◉·◉ ○●○○○○◉ ·◉● ·● + ◉·○●●◉○○·◉○· · ○○○○●○○●◉◉○●· + ◉○○◉· ○◉● ●○ + ○◉·○◉●◉● ●◉◉○●●● + ○○ ·●·◉◉··●·◉◉● ○◉◉ + ○·◉○○○◉○○●●● ○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_36.txt b/codex-rs/tui2/frames/dots/frame_36.txt new file mode 100644 index 00000000000..6a26abaea68 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_36.txt @@ -0,0 +1,17 @@ + + ○○●○●●●●○●●○ + ○○ ○●◉◉◉●○●◉●● ●●●○ + ●○●○●●●◉● ·· · ●·●●·●●·● + ○· ●●○○○○ ●●·○ · + ●◉○◉○●·○·○●◉ ○○◉● + ◉ ●◉· ●○ ·◉● ○○● + · ◉ · ○ ○○ ·○·● + ··○· ○○ ○◉◉ ○· · + ·● · ◉◉ ●● ●●●●○●●●●●○ ·○·· + ○●·○ ●·○○◉·◉ ··○○○○○○○·· ○◉●· + ● ●·○ ●○●◉○ ●●○○○○○ ·● ◉ + ●● ○◉◉ ●◉· ◉ + ○◉··●·● ●●● ◉●· + ●●○ ●●○○◉·○●·◉ ◉ ●◉●● + ·●●◉·○●●○●●●●●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_4.txt b/codex-rs/tui2/frames/dots/frame_4.txt new file mode 100644 index 00000000000..b4496013b5e --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_4.txt @@ -0,0 +1,17 @@ + + ○◉○◉●○○●○●●○ + ○◉ ●●·◉ ·○○◉●◉· ◉●●○ + ●●○···●● · ·○·○○●·◉○○○ + ◉◉·●· ◉●● ·●●○●○ + ◉·◉○·○·○◉○·● ○ ○·●○ + ◉··○· ●○··◉●○ ● ○○·● + ○·◉· ●●●○● ·● ● ◉· + ·◉○ ●·◉●○·· ··· + ○· ·○ ◉·◉ ○◉◉●·●●●○○○○●● ··● + ·○◉ · ●◉◉◉●○··○○·●◉○●◉◉○◉·◉◉ · + ··◉●○●○○●○◉ ●●●●●●●●○◉···· + ●·○ ·○· ●○◉◉●●● + ○○●◉○ ●○ ○◉○●●◉·◉ + ···○○●●○◉◉○◉●●○●●○·●· + ●○○·◉·○○○○○◉◉●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_5.txt b/codex-rs/tui2/frames/dots/frame_5.txt new file mode 100644 index 00000000000..0905c495b26 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_5.txt @@ -0,0 +1,17 @@ + + ○◉○◉◉●○○○●●○ + ○● ●◉●◉··●·○●◉○· ●● + ●○◉○●·● · ○○·●○ ●●● + ●·◉·◉·◉○● ◉○····○ + ●○◉●◉○○●·○○● · ○·○·○ + ····◉ ○○·○◉◉·○ ●◉·· + ·◉· ● ·○○·○ ·● ●●·●·● + ·◉·○ ●○○·◉○· ●·○· + ·●·○● ◉○· ●·●○○·●·○○○●●○ ·◉· + ○○·○●●◉●● · ◉◉○○○●○◉●◉·◉●·· + ○●··●·●○○◉●· ●● ●·◉·○◉ + ○○●◉○○ · ◉○◉●●○ + ○◉●○·○●○ ○○●●●●◉● + ○·●·●●●○◉·○○●●○●○·◉● + ○◉●○● ○○○◉○●●●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_6.txt b/codex-rs/tui2/frames/dots/frame_6.txt new file mode 100644 index 00000000000..3f96b667617 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_6.txt @@ -0,0 +1,17 @@ + + ○◉○◉◉●○○●●○○ + ●○◉◉●·○●●·●●◉○●●●○ + ●●●···● · ·◉○●●●◉○○ + ◉●·●·●○○● ○●····● + ●○···●○○●●○ ◉○○·○● + ◉◉◉·◉·○●◉·○○·● ◉○○◉· + ○ ●·· ○○·◉○○ ○●·○· + · ··· ○·○·◉·· ·●○·· + ○◉·◉· ◉●··●◉◉●○·●·○○●●○◉○◉· + ··○●◉○○○◉● ·◉○●●··●◉○○○·●·○ + ◉ · ◉···◉◉ ● ●● ·○○◉· + ● ◉ ◉●· ●◉○◉◉·· + ◉·○○●●● ○●○○◉●●● + · ·●● ● ◉·○○●●●● ◉◉● + ○◉◉●●●○○○·○●●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_7.txt b/codex-rs/tui2/frames/dots/frame_7.txt new file mode 100644 index 00000000000..aa52e1b869d --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_7.txt @@ -0,0 +1,17 @@ + + ○◉·◉●●○○●●○ + ●●·●○·●●●○○○◉ ·●● + ● ●···●· ·○·· ●◉○●● + ○○····◉● ○·○●·◉● + ·●·●··○·◉○● ◉ ●··○ + · ◉·· ·●○◉·○ ●◉··· + ·○ · ○○·○·○· ●●○·· + ○ ◉·· ○●◉·●·· ● ··· + ◉ ·● ● ○◉·○◉··◉◉○··○◉●○·· + ·●●··○◉··◉●·◉○··○○●○●◉○·○· + ○ ○○◉●·●○◉ ●●●●●● ○◉·· + ●○ ◉○◉○· ● ◉◉◉◉● + ●◉●●○○○○ ●○·●●◉●● + ○○●○·●○◉·○○○●●·●●◉ + ·● ·◉·○○●○◉●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_8.txt b/codex-rs/tui2/frames/dots/frame_8.txt new file mode 100644 index 00000000000..5791ce70e48 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_8.txt @@ -0,0 +1,17 @@ + + ○○●○◉●●○●●○ + ● ●◉···●○○ · ● + ○·○●·◉◉● ● ·◉·●○●○● + ○●○●◉·●●· ● ○○··○ + ● ◉·◉●○·◉○ ◉●○··● + ·●●·○○○○○·○● ○●··· + ·●··· ●○◉○··· ·○·◉·● + ○●··◉ ·●○◉·●●◉ ◉·◉·· + ○ ○··○○ ◉●○··◉●●●●●○··· + ○●◉○○· ●·●··◉·○○○··○● + ◉◉○○·○···◉●○●○● ○● ◉○· + ●● ·○··· ◉●◉◉◉● + ●○○●○○○○ ○◉○○·●◉● + ◉◉●·●● ◉◉●●◉◉○●·· + ··○●●○·○●●◉○· + ·· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_9.txt b/codex-rs/tui2/frames/dots/frame_9.txt new file mode 100644 index 00000000000..35588ee1ee7 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_9.txt @@ -0,0 +1,17 @@ + + ◉○○●◉●●○● + ◉●○◉··● ○ ○◉○○ + ◉ ●··◉●○○○◉ ○○·● + ··◉◉○○ ○○●·○○·●● + ◉● ◉○● ·●● ●◉●●◉●·· + · ··· ○·○◉○○ ·●·●··· + · ·●○·●●·○○● ◉●◉ ··· + · ·◉○●○···○◉○○○· ·· + ●· ◉· ····●··●··· + ·○···○●·○·○····○○··· + ·○◉○○·◉ ●●●●·· ◉○· + ○···○●· ●◉ ○◉·· + ○●○·○○ ◉◉○◉·◉● + ○◉ ○○·●◉●○◉◉●·· + ●◉○ ●○○·◉●◉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_1.txt b/codex-rs/tui2/frames/hash/frame_1.txt new file mode 100644 index 00000000000..45adbbac247 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_1.txt @@ -0,0 +1,17 @@ + + -.-A*##**##- + -*#A**#A#**..*- -█#- + #.*.#**█-- -█*-█.*...# + **-**█##- A*.*.# + *-*A█-.*-** █..**# + .* #- .*A*..# █.*..# + #-█-* █*.*A█.- .A. + ..-.- #AA.*.* █-. + .*.█- *..-*.█..######-## *█ . + -.** -*#- A* .█.---.A###.A#A#. + *--█- -*#.A- --*██* -*█A#*-A + *-# █# - #█A*-. + -*#-*#- -*-#.-#█ + -*#*- *#A****.**#-#.* + -*█*...---#-*#*█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_10.txt b/codex-rs/tui2/frames/hash/frame_10.txt new file mode 100644 index 00000000000..0e9a76d4d8f --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_10.txt @@ -0,0 +1,17 @@ + + -#****##- + *█-#*#*.A-*# + --#..A..-.#*## + .--A*.*-.*#██**# + A *..█.- █#A-A.A + .- █.*.AA* --█. + * .*█*....* .A# + █ ..*#A#..---.*.. + █ .* **.*..#*#*.. + . #.*#.-A-*---.*- + # █.....██ *#█*.- + *-█#*#*█ -.██#AA + .█ ....*#A#A.A- + *-**A.#*- AA█ + *# -**-#** + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_11.txt b/codex-rs/tui2/frames/hash/frame_11.txt new file mode 100644 index 00000000000..b7e743b218b --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_11.txt @@ -0,0 +1,17 @@ + + #****##- + A█ *...**# + A█--..***A*# + .--..**#-*AA + -█.**.#*█*-..# + .#-..#-**.-A.. + ** #......-.** + . ..A..*A .█. + A █.A..*A#.-. + ** -.A*#█A.-.- + █-- ....*A.**. + *--A.-*A***.- + - *.**--.*█ + *# *..#-A*- + *- █*#-#- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_12.txt b/codex-rs/tui2/frames/hash/frame_12.txt new file mode 100644 index 00000000000..0c6c85043f9 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_12.txt @@ -0,0 +1,17 @@ + + #***#. + #*--A.#* + █ .A*## + A#...**█. + . █.....A. + .█..*-█-.█ + . .A*..* + . A#*..█ + *# .A..#.. + . A.***- + .. .█***A + # *. -#. + A **.#**. + █-A█#.*.- + * █.*.A + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_13.txt b/codex-rs/tui2/frames/hash/frame_13.txt new file mode 100644 index 00000000000..097cd508d7e --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_13.txt @@ -0,0 +1,17 @@ + + A***# + .--.. + .--.█ + .**.- + * .. + █A-.. + # .. + # █. + A# -.. + .█ #*. + █-.. .- + .---. + .##.- + *--.* + # .█* + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_14.txt b/codex-rs/tui2/frames/hash/frame_14.txt new file mode 100644 index 00000000000..8eca9095040 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_14.txt @@ -0,0 +1,17 @@ + + #**** + #A--.* + .-... . + ..A.█-.# + ...* . + .*.. █. + .... . * + ..*.- * + .... █ + █-AA *A + *.██#.#. + .*..---. + A █.***- + .*A*--A + -* █*A + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_15.txt b/codex-rs/tui2/frames/hash/frame_15.txt new file mode 100644 index 00000000000..cbf646ab35c --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_15.txt @@ -0,0 +1,17 @@ + + ##***.- + -#*..-A*# + AA.***.█*. + .A- AA#.--. + ..*█**..A█-. + *..-..AA. █ + .. ......#. + ..A..#... █-. + ..###*...-.A. + .-.*A.*.*. .. + .A....-. - * + .**.-..*- * + █.*# AAA- *- + .-..**.#*A + *-*----A + -- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_16.txt b/codex-rs/tui2/frames/hash/frame_16.txt new file mode 100644 index 00000000000..82698755af1 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_16.txt @@ -0,0 +1,17 @@ + + -*#█**#.- + A-..*..*.** + .AA█*A**.. █* + AAA **-*.*..**. + ..*-A*█*..*A.-* + ...*.█ .** . ... + **. A-#....A*█# . + .A* .-..A...█* -. + **...#.A-..█*# A + *█--#****..-. #-. + ...#██A**.*█*...- + ..* .- -AA# A + *.* . A#A-#. + *..█-*.AA#-. + █A.-*A--** + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_17.txt b/codex-rs/tui2/frames/hash/frame_17.txt new file mode 100644 index 00000000000..57d02179e70 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_17.txt @@ -0,0 +1,17 @@ + + #*###**.#- + -***...***.#█-# + #**A-**-##█...-█* + A.A-A.█-- █.**...** + .A- . .█A***AA # + -**#A- #AA. A#*A..A + * *A. #AA.- *█..A*. + -█*. AA..A.#..*** + #A*A***#.*-.*-A*... . + .█-*--.A**A..* *#█#* + ***A███*****-.*AA* *█ + *..-* -A..- * + -** **- #█#A--A + # .*#**#--**A.█ + -#*A-##.*-#* + - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_18.txt b/codex-rs/tui2/frames/hash/frame_18.txt new file mode 100644 index 00000000000..ef524a0ed91 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_18.txt @@ -0,0 +1,17 @@ + + -**#**#*.#- + -#█-*###.--#█#**- + -AA.*█**█#█.-█#AA* * + #..*.#-█* █*--A*.*****# + -*AA A. -█#-*A.A.-****- + A*. ##..---A#*#.█-A-A-A. + ..*AA #A*A.█-A█*.*.*# + █*-.. -*█..*- .*.█ + *██ #******#..#.*A*# .*.- + -**.*** ..A--*-...-*.-* # + .A.*-██*****- *--A.A*A--- + ** #*# --*█A*-█ + *-*#--. -*--A*#- + █..**##***-█*-A-#* + █..-A--#.#█*#█ + - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_19.txt b/codex-rs/tui2/frames/hash/frame_19.txt new file mode 100644 index 00000000000..80a9abf0128 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_19.txt @@ -0,0 +1,17 @@ + + --**#.#**..- + -#...*A***-*-#.*## + #***A█.#*- -*A#-*** + A*.*#-- -**##-.*# + ..A-A- #.-█A█.* .*# + .*A*█ -.*.A .A █.*. + -*..█ A*-A.##- AAA#. + .... .*█*.█# . ... + .*.*#******....#***.* * █.. + -*A#.**A-*#*#-# -- A*-*#A..- + █*█A#.█████**█ -*-A.AA AA█ + **-*-* -AA-AA█ + █ -*..*# #**.A.*- + ██..*A.#--**A*█ **.█ + █..-A-█-#.-*-- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_2.txt b/codex-rs/tui2/frames/hash/frame_2.txt new file mode 100644 index 00000000000..843df90f283 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_2.txt @@ -0,0 +1,17 @@ + + -.-A*#***##- + -##A**#...*..*-█.*#- + #.*.***.*-- -█***A..*.# + -.A-A*█##- -#*.A.# + #-█A*-.*-**- -#.*.# + █A#█- .**#A*# **.*. + #.█*. -A.*.-.# A█.█. + .█ *- #.*..** .█A.. + *██*. *.---.█..#########.-..* + -*█. A..█ A* .**----..--.#A . + *** * **...█ -█*******█.#*.. + █*-.A# - ##*A#* + .*--.## #*-# -.█ + -.-*--█*#A****.**--..* + -*#*#..----.*A*█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_20.txt b/codex-rs/tui2/frames/hash/frame_20.txt new file mode 100644 index 00000000000..b588df38946 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_20.txt @@ -0,0 +1,17 @@ + + --#*..*#*#-- + #**█*#-#***..--**## + ##█-#*█.*█ -█-█*#*.#- + -.█-*.* -##.*A*** + -. #.* -A -..-**..* + A-#.█ #*█*.█A █*..* + *.*#. #* .#*- #*.A + .--A- ██*A.█# ..█. + .A..*#********-#█*--*** A .* + █.-.-█ A------.* ***█.** #A .. + *#-*████████*█ *#*..#. A* + * -*A# -A*..A + *--*#.- #**-**█ + -#-.█.#-**#A█.█-#A*█ + -*#.- *----.*██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_21.txt b/codex-rs/tui2/frames/hash/frame_21.txt new file mode 100644 index 00000000000..0d1fc7ec26e --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_21.txt @@ -0,0 +1,17 @@ + + --#*#**#*##-- + ##*----#.*.#*---*#- + **--#*-█- --.*#--*# + #*.*██ -#**#**# + A#A*- #.A-* **--# + A█A. #**-AA **-# + .█.- - A-#A█ ***█ + -* ** █.# . . + . . ##*****### -.#█.# *A . + . .* .- -#. -█-*.# A█.A + * █. --███ █- █*-#* A- # + █# A*- -*█ A + *- --- -.**█ #- + **- *█*#A..*A**██ -- + *##*#----#-*#*- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_22.txt b/codex-rs/tui2/frames/hash/frame_22.txt new file mode 100644 index 00000000000..8fbfdb57138 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_22.txt @@ -0,0 +1,17 @@ + + -##*#**#*##- + -#A*..-A.A*..*.#A*#- + #***.***- -█****#--## + **.**█ ##...-** + *..A█ #-A.*..A*█. + *.*A- #.A.*-A- *.**. + .#**A .#.**A█ ..A. + ..*.- .*..-* .-.█. + ..#█# #.******##-**.- *# -.*█ + -.* *-**# A#█.AA. ***-█.## A** + ***-. █████**█- -A*#-AAA█#- + ***-*# -*#█.#█ + ██#* ##- -.*#█*-A + █A*##█*.**#**A**███#* + █.█.-*----*-.**- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_23.txt b/codex-rs/tui2/frames/hash/frame_23.txt new file mode 100644 index 00000000000..ef2f8adb709 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_23.txt @@ -0,0 +1,17 @@ + + -##-*#*##*#-- + -*.#.*-#-**#.-.*.#- + #█#.█#**- █.A#.**..# + A*.**A- -***..** + AA.-A█ #A##█*A**.* + #*.█A- -***██A*█..*.# + -..█. AAA. -.- █ .█* + .A. . .█.#-*# . *A. + -█* .#.********.-*.-█*- . ... + .**-.█*█ A-* *.-*#. *-. + A█. . -███████ █-A**.-A-A- + #..-.. #A.*-A- + * .#█A-- -.█**A#A + ***#**#*.**-##█--#*- + █. ***.--#--**- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_24.txt b/codex-rs/tui2/frames/hash/frame_24.txt new file mode 100644 index 00000000000..09a7fd520cb --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_24.txt @@ -0,0 +1,17 @@ + + -.-*##***#- + #█-A█.--.**##-█# + #██**A#█. .-A*.**# * + #█AA#A.*#*-.A- -**.*-█ + # AA*A# -██A*-.--*.#█ + .#*#*- *##A ..* A█* + . .. #A-#A█A-*.*-.- + . *.. .#*A*-..-A.-.. + .. .***A##...##-*#-. . .. + - A *.A##**##..-*#*. .*. + * █*.**██████- *#.**.*A#- + * *#** . █A*A + *.**-.. -*█-AA-* + .-*#*-█..A***.--A- + *A-*#**--**#* + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_25.txt b/codex-rs/tui2/frames/hash/frame_25.txt new file mode 100644 index 00000000000..af8bb947f60 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_25.txt @@ -0,0 +1,17 @@ + + -#***####- + # *#*--**.*..# + A.AA##█..#***...- + .#*. A *. █*.#**A + #--*#A█-*A-.█ .AA*A* + *#...-*#.-.A A.#.AA.# + .A.*. .--A*-A.*A-█.*. + ...A..█ .-**A.*-.*..* + -#*.█.--****#..**.#*.* + A-█.A.#.-..**-.***A*. + *.*A*.-██████#A.*.A.- + .-█.*#**-#*A-A#AA.A + ---.-*-**A#-A-..A + *.***-**.** *.* + .#█**##-#.* + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_26.txt b/codex-rs/tui2/frames/hash/frame_26.txt new file mode 100644 index 00000000000..7ff85c300af --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_26.txt @@ -0,0 +1,17 @@ + + -****█## + A*.*A*.#*.# + A*██.**.*██*# + **█#...-..-#A- + .-#*...█**A*.** + * █.AAA█*-*█**A + . *█*..A..**█# + A- *...--*..A.. + . .***#.#**A*.. + .-.A#.--#****. + .-█A.*█*#A-#*.. + *-..A* AA█..- + * *.* --A.A# + .█***█*-*** + *AA-*.-** + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_27.txt b/codex-rs/tui2/frames/hash/frame_27.txt new file mode 100644 index 00000000000..06e988b0761 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_27.txt @@ -0,0 +1,17 @@ + + ****## + .*AA.A* + *█- *.A█. + ...-..*.. + . -.--█* + .-.-█-...- + * █..-*.. + . -...*█.. + .. . .#.... + █* .█-.-*.. + .- --**#A█ + ..##..-A. + .**#*#A.- + *--.**AA + █*█ -*A + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_28.txt b/codex-rs/tui2/frames/hash/frame_28.txt new file mode 100644 index 00000000000..0e258181458 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_28.txt @@ -0,0 +1,17 @@ + + A*** + .#-.- + * *.- + .--*. + . .. + . -█. + -.. -. + .-██. + A* -. + ...A.. + . -. + .*#*. + .**.. + *--A. + . .#. + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_29.txt b/codex-rs/tui2/frames/hash/frame_29.txt new file mode 100644 index 00000000000..7f2ddab00a4 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_29.txt @@ -0,0 +1,17 @@ + + #****# + #.*A ** + -#.. ██. + ....-*#A + * .█. . + .#... -- + .**... - + ..*.. + ...*. - + -.*.. - + *..A.- * + .A... -. + ....-.-. + .*.*A█.█ + ..* .# + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_3.txt b/codex-rs/tui2/frames/hash/frame_3.txt new file mode 100644 index 00000000000..8cce426bb4a --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_3.txt @@ -0,0 +1,17 @@ + + -.**##****#- + -##A*..#.█**.*--.*#- + #*A**.*██- -█. **#A-.# + A#A*.*##- -** ..* + █A*.A-A..**- *-*..- + A.**A .#.**** . **. + ** . █**.-█.# .*A.# + #-.#. #-.█A*. .-.*. + -██. *.*#..*-.*##---##-.-█*. + #*A*# .A*A**. .*.--# *.#**-.*.- + █-. . █..##█ █***███**#AAA#A + █*. *# - A-#-AA + *-AA**# ##*A█#.█ + *-##-**-****#A***--#█ + -*#*-A.----.*A-█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_30.txt b/codex-rs/tui2/frames/hash/frame_30.txt new file mode 100644 index 00000000000..24a2165e45b --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_30.txt @@ -0,0 +1,17 @@ + + -*█**## + -A*..**█** + ██-.#**.##* + ..*.A.-*.* *# + **..-*.#.# . + .*.-# *...... + *A...*- *A*. *- + █. █#*.#.█.- - + █.-█*.*.*A.# - + █..*.--.. ## - + *..**-*...#.. + .*AA#A**..█ . + *.*..A*..#.A + #-##*-A.#A█ + *-..---*█ + ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_31.txt b/codex-rs/tui2/frames/hash/frame_31.txt new file mode 100644 index 00000000000..65f139ab962 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_31.txt @@ -0,0 +1,17 @@ + + -.A*#*#*# + -#**##-█#*█*# + #.*AA#*--.█.-** + #.A#--**-#**.*..* + -....#-**-#█*.A-- + *.--.-.██***#.A.#█. + A.. .-*.*.*A**.*.A. + **# ..█.* .-██..*.. + .*#-.█-...A#...- . + █...AA..---*-.*.. * + -*-A#.# A***-A.█.- + ...***# -** . + *.**- # . .*-A- + A.*.A-.-#.█#.- + ██*.-#*A.** + ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_32.txt b/codex-rs/tui2/frames/hash/frame_32.txt new file mode 100644 index 00000000000..6cbec21aeca --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_32.txt @@ -0,0 +1,17 @@ + + #####**.#- + **.**.*..**..# + A-**-##-A--*.**█*# + █AA.-#-*- - #-*.## # + AA.#.**█*.* █-#.*A***# + *A**#**.-A..#- .#A * + *.█*#-█*..##.#*-A-AA.█- + ...#- █-..█A█.*--█*.██- + *..-- █-.█--.###...*.█*- + ███--.**█*-----..*.*.-- + #**..A .*██***-*AAA.A + *#.-** #*.A█. + █**...#- -A-.A # + *-*█.#.***--#█-A + .**--##A*-██ + --- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_33.txt b/codex-rs/tui2/frames/hash/frame_33.txt new file mode 100644 index 00000000000..a661feb2aff --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_33.txt @@ -0,0 +1,17 @@ + + -##*#*#*..- + -#*..***.A.-.*.*. + -A*.#*##*█- █*A..#█# + #*A.A-.#- *..*-* + -..A-..A*-* .#**- + AA# *-**-*# A*A-* + ..# *#*.-.* .*.. + *.## #██*A*# *█.* + ... . #- A*###***#..#A-.*. + -A.██AA#A-..-----..*---... + ***# .A.A A ████*** ..#A-- + *#**#█# AA*A*A + **█*#**# -#.#A**█ + *.█**#.#*#-█*#**.* + █**##----#**-█ + ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_34.txt b/codex-rs/tui2/frames/hash/frame_34.txt new file mode 100644 index 00000000000..3427025326c --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_34.txt @@ -0,0 +1,17 @@ + + -#*####*.#- + -#*#A**.█.#*****#*- + -AA#.*█A.*- --**#█**# + #A-.*-A#- *.#*** + AA.*-A. .█ # ****- + #A*.-A **█.*** *#*.* + .*-.- █*-*.-- .*.- + *.*█A .*-.A█. A -* + #█A.█ A*AA█-**######.--█ .-- + ..*#A##-█AA█...-..---#A-..-.* + A-*# .*.*- █**----- #A#*.█ + A#****- A*..A█ + *#-.#**# -#*█-A#- + -*A.█.##*##****-#*A█ + █.***-----****- + --- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_35.txt b/codex-rs/tui2/frames/hash/frame_35.txt new file mode 100644 index 00000000000..e0919ec5d0e --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_35.txt @@ -0,0 +1,17 @@ + + -##**###*##- + #***..**#█.*.A█.**- + -AA.*#*.-*█- -█***.█.*# + #AA#A#*#- *#-*.- + AA..AA* █**# .**.# + *A*A#- █.-█..*- .**. + * .A ** █*-. .-** + ...A A***A█ A █A + A #- #..A*█A.**-*-*#.. ..** + .#-*█ AA*█*#A.* *#----A█.A█ .█ + A-*#█A*-.A-- -█----*--*AA-#. + █.-*A. -A* #- + **--*#A# #*A-**█ + █--█.*.-A..#.*A*█-A*█ + █-.**--A--##*█- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_36.txt b/codex-rs/tui2/frames/hash/frame_36.txt new file mode 100644 index 00000000000..0355f68b47c --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_36.txt @@ -0,0 +1,17 @@ + + --#*####*##- + -*██*#A*A#*#*##█**#- + #*█*##*-*█-- -█*.*#.#*.# + -.█*#---- █*.*█. + #A-**█.*-*#. -*.* + A #A- *- █.A# █*-* + . * - * █** .-.# + ..*. -* -AA *. . + .██. AA #*██###-#####- .*.. + -*.* #.--A.A -.-------.. *A█- + █ *.* #-#A- █**-----█ .#█A + █#█**. #A.█A + *A..#.# ###█A#- + *#-█***-*.-#.*█-█#*#█ + -*#A.-##-####**█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_4.txt b/codex-rs/tui2/frames/hash/frame_4.txt new file mode 100644 index 00000000000..2b4b7c670bb --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_4.txt @@ -0,0 +1,17 @@ + + -.**#**#*##- + --█#*.A .**A**.█A*#- + #**...#*█- --.*-*.A**- + AA.#.█.## █.***** + A.A-.*.**-.# *█*.** + A..-. █-..A** █ **.# + █*.*- █***#█.# # █*. + ..* #.A*-.. ... + -. .- *.A█-AA#.###----## ..* + .*A . #AAA#-.-**.#.-#AA-*.AA█. + -.*█*#**#*A █*******█--...- + #.*█.-- #-*A**█ + **#A- #- -.-#**.A + ...*-*******##**#*.*- + ***.A.-----*-*- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_5.txt b/codex-rs/tui2/frames/hash/frame_5.txt new file mode 100644 index 00000000000..c71575690bb --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_5.txt @@ -0,0 +1,17 @@ + + -.***#***##- + -#█#A*A..#.*#*-.█## + *-A*#.*█- █-*.** #*# + #.A.A..*# --....- + #*A*A--#.-*# - *.*.- + ....A *-.**A.- #A.. + .A. # -*-.*█.# ██.#.# + .A.-█ #-*.A-. #.-. + .#.-* A-.█#.**-.#.---##-█... + -*.*##A#*█ .█ AA**-#*.#A.A*.- + *#..#.#**A#- █**█████#.*.*A + **#A** - A-A*#- + -A**.*#- -*##*█** + -.*.#*#**.*-##**-.** + --*-*█-----##*- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_6.txt b/codex-rs/tui2/frames/hash/frame_6.txt new file mode 100644 index 00000000000..799e3a1cf5a --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_6.txt @@ -0,0 +1,17 @@ + + -.***#**##-- + #-*A*.**#.##*-#*#- + ###...*█ -█..**#*A*- + A#.*.*-*# *#....# + █*...***#** .**.*# + *-A.A--#*.*-.# .**A. + - #.. -*.A-* -#.-. + . ... -.*.A.. .#*.. + -..-. A█..**A#-.#.--##-.*A. + ..*#A---*# .A**#..#****.#.* + A . A...*A█ █*████*█-**A. + # * A#- #A-AA.- + .-*-**# -#--A*## + -█.*#█# *.--##** A*█ + --**##-----##* + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_7.txt b/codex-rs/tui2/frames/hash/frame_7.txt new file mode 100644 index 00000000000..4a3f9f202fb --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_7.txt @@ -0,0 +1,17 @@ + + -..**#**##- + #*-#*.#*#**** -*# + * #...*- --.- *A-*# + **....*# -.**.A# + -█.*..-.A*# . *..* + . *..█ -█**.* #-... + -*█. --.*.*. **-.. + -█A.. *#A.*.- █ ... + A█.* #█-A.*A..**-..****.. + .#█..*A..A█..*..**#*#**.*. + * *-A*.*-A ******██-A.. + █- A-A-- #█-A*A█ + █*##***- #-.#*A*█ + *-█*.***.---#*.*#A + █.*█.A.--#-*** + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_8.txt b/codex-rs/tui2/frames/hash/frame_8.txt new file mode 100644 index 00000000000..4bc5a6f1186 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_8.txt @@ -0,0 +1,17 @@ + + --#-*##*##- + #█*A...*** . █# + -.*#.AA* #█.A.**#-# + -***A.##. █ *-..* + █ A.A#*.A* .█*..# + .█#.*---*.-* *#... + .*... █-**.-. .-.A.# + *#..A .#*A.#*. .-A.. + * *..-- A**..A#####*... + **A*-.█#.#..*.*--..*█ + AA**.*...A█-*-*█**█A*. + █#█.*.-- .#AAA█ + █*-****- -A*-.#A* + .A█.#* **#**A*#.- + ..-***.-##A*- + -- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_9.txt b/codex-rs/tui2/frames/hash/frame_9.txt new file mode 100644 index 00000000000..db3507db59c --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_9.txt @@ -0,0 +1,17 @@ + + .*-*A##*# + A*-A..*█* --*- + A #..A*--*A *-.# + █ ..A**- --#.*-.## + A*█***█.*# *.█#A#.. + . ...█-.**-- .█.*... + - .**.**.**#█.#A -.. + . .A-#*...-A**-. .. + █#.█ A.█....#..#..- + .-...-#.-.-....--... + █ --A**.A█****..█A*. + *...*#- #A -A.- + *#*.** A*-*.A* + --█*-.*A#****.- + █-- ***.*#A█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_1.txt b/codex-rs/tui2/frames/hbars/frame_1.txt new file mode 100644 index 00000000000..ab8be3eb1e1 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_1.txt @@ -0,0 +1,17 @@ + + ▂▅▂▅▄▇▇▄▄▇▆▂ + ▂▄▆▅▇▃▇▅▇▃▄▁▁▄▂ ▂█▇▂ + ▆▁▇▁▇▇▇█▃▂ ▂█▇▂█▁▄▁▁▁▇ + ▄▇▂▃▇█▆▆▂ ▅▇▁▄▁▆ + ▃▃▄▅█▃▁▃▂▃▃ █▅▁▃▃▆ + ▁▇ ▇▂ ▁▇▅▄▁▁▆ █▅▃▁▁▆ + ▇▃█▆▇ █▃▁▇▅█▁▂ ▁▅▁ + ▁▁▂▁▂ ▆▅▅▁▄▁▇ █▂▁ + ▁▄▁█▂ ▄▁▁▃▃▁█▅▁▇▇▇▇▇▇▂▇▆ ▄█ ▁ + ▂▁▄▇ ▂▄▇▂ ▅▇ ▁█▁▂▂▂▅▅▆▆▆▁▅▆▅▆▁ + ▃▃▂█▃ ▃▃▆▅▅▂ ▂▃▇██▇ ▃▇█▅▆▄▂▅ + ▇▃▆ █▆ ▂ ▆█▅▇▂▁ + ▃▃▆▂▃▇▂ ▂▄▂▇▁▂▇█ + ▃▇▆▃▂ ▇▇▅▄▄▄▄▅▄▇▇▂▆▁▇ + ▂▇█▇▁▁▁▂▂▂▆▂▄▇▇█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_10.txt b/codex-rs/tui2/frames/hbars/frame_10.txt new file mode 100644 index 00000000000..5e565ce40b9 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_10.txt @@ -0,0 +1,17 @@ + + ▂▇▇▇▇▃▇▇▂ + ▇█▂▇▇▇▃▁▅▂▇▆ + ▃▂▆▁▁▅▁▁▆▁▇▃▆▆ + ▁▂▂▅▃▁▄▂▅▃▆██▃▃▆ + ▅ ▄▁▁█▁▃ █▆▅▂▅▁▅ + ▁▂ █▁▇▁▅▅▃ ▂▂█▁ + ▃ ▁▇█▇▁▁▁▁▇ ▁▅▆ + █ ▁▁▃▇▅▇▁▁▆▂▂▅▃▁▁ + █ ▁▃ ▃▃▁▄▁▁▇▃▇▄▁▁ + ▁ ▆▁▃▆▁▂▅▂▇▂▂▂▁▇▂ + ▆ █▁▁▁▁▁██ ▃▆█▃▁▂ + ▃▂█▆▃▆▇█ ▂▁██▆▅▅ + ▁█ ▁▁▁▁▇▆▅▆▅▁▅▂ + ▄▂▇▇▅▁▇▄▂ ▅▅█ + ▇▆ ▂▇▃▂▆▄▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_11.txt b/codex-rs/tui2/frames/hbars/frame_11.txt new file mode 100644 index 00000000000..5305252a8d1 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_11.txt @@ -0,0 +1,17 @@ + + ▆▇▇▇▇▇▇▂ + ▅█ ▄▁▁▁▃▃▆ + ▅█▂▂▁▁▄▃▇▅▃▆ + ▁▂▂▁▁▄▄▆▂▄▅▅ + ▂█▅▄▃▁▇▃█▄▂▁▁▆ + ▁▇▂▁▁▇▂▄▄▁▂▅▁▁ + ▇▇ ▆▁▁▁▁▁▁▂▁▄▃ + ▁ ▁▁▅▁▁▃▅ ▁█▁ + ▅ █▁▅▁▁▇▅▇▁▂▁ + ▇▇ ▂▁▅▄▆█▅▁▂▁▃ + █▂▆ ▁▁▁▁▄▅▁▃▃▁ + ▃▂▆▅▁▂▇▅▇▇▄▁▂ + ▂ ▇▁▃▃▃▂▁▄█ + ▃▇ ▇▁▁▆▂▅▇▂ + ▃▂ █▇▇▂▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_12.txt b/codex-rs/tui2/frames/hbars/frame_12.txt new file mode 100644 index 00000000000..cebfe226e1e --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_12.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▅ + ▆▄▂▂▅▁▆▃ + █ ▁▅▃▇▆ + ▅▇▁▁▁▄▄█▁ + ▁ █▁▁▁▁▅▅▁ + ▁█▅▅▇▃█▂▁█ + ▁ ▁▅▃▁▁▃ + ▁ ▅▇▃▁▁█ + ▇▆ ▁▅▁▁▇▁▁ + ▁ ▅▁▇▄▇▂ + ▁▅ ▁█▄▇▇▅ + ▆ ▇▁ ▂▆▁ + ▅ ▇▇▁▆▇▃▁ + █▃▅█▆▁▃▁▂ + ▃ █▁▃▅▅ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_13.txt b/codex-rs/tui2/frames/hbars/frame_13.txt new file mode 100644 index 00000000000..566cc4ffa30 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_13.txt @@ -0,0 +1,17 @@ + + ▅▇▇▇▆ + ▁▂▂▁▁ + ▁▂▂▁█ + ▁▇▇▁▂ + ▇ ▁▁ + █▅▆▁▁ + ▆ ▁▁ + ▇ █▁ + ▅▇ ▂▁▅ + ▁█ ▇▄▁ + █▂▅▅ ▁▂ + ▁▂▂▂▁ + ▁▇▆▁▂ + ▇▂▂▁▄ + ▆ ▅█▄ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_14.txt b/codex-rs/tui2/frames/hbars/frame_14.txt new file mode 100644 index 00000000000..380790e11c9 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_14.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▄ + ▆▅▂▂▅▃ + ▁▂▁▁▅ ▁ + ▁▁▅▁█▃▁▆ + ▁▁▁▃ ▁ + ▁▇▁▁ █▁ + ▁▁▁▁ ▅ ▇ + ▁▁▃▁▂ ▃ + ▁▁▁▁ █ + █▃▅▅ ▄▅ + ▃▁██▆▅▆▁ + ▁▇▁▁▂▂▂▁ + ▅ █▁▄▄▄▂ + ▁▃▅▇▂▂▅ + ▂▇ █▄▅ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_15.txt b/codex-rs/tui2/frames/hbars/frame_15.txt new file mode 100644 index 00000000000..47d169e98bc --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_15.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▁▂ + ▂▆▄▁▁▃▅▇▆ + ▅▅▁▇▄▃▁█▇▁ + ▁▅▂ ▅▅▆▅▂▂▁ + ▁▁▄█▄▃▁▁▅█▃▁ + ▃▁▁▆▁▁▅▅▁ █ + ▁▁ ▁▁▁▁▁▁▆▁ + ▁▁▅▁▁▇▁▁▁ █▆▁ + ▁▁▇▆▆▇▁▁▁▂▅▅▁ + ▁▂▁▄▅▁▃▁▇▅ ▅▁ + ▁▅▁▁▁▁▂▁ ▂ ▄ + ▁▇▇▅▃▁▁▃▆ ▄ + █▁▃▆ ▅▅▅▂ ▄▂ + ▁▃▁▁▃▃▅▇▃▅ + ▃▃▇▃▂▂▂▅ + ▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_16.txt b/codex-rs/tui2/frames/hbars/frame_16.txt new file mode 100644 index 00000000000..3b1fb1fc5d4 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_16.txt @@ -0,0 +1,17 @@ + + ▂▄▇█▇▇▇▁▂ + ▅▃▁▁▇▁▁▃▁▄▃ + ▁▅▅█▃▅▄▃▁▁ █▃ + ▅▅▅ ▄▇▂▃▁▃▁▁▇▇▁ + ▁▁▄▂▅▇█▄▁▁▇▅▁▂▃ + ▁▁▁▇▁█ ▁▃▄ ▁ ▁▅▁ + ▃▃▁ ▅▂▆▁▁▁▁▅▇█▆ ▁ + ▁▅▄ ▁▂▁▁▅▁▁▁█▄ ▂▁ + ▃▃▁▁▁▇▁▅▃▁▁█▇▇ ▅ + ▇█▂▂▆▄▄▃▇▁▅▂▁ ▆▂▁ + ▁▁▁▇██▅▇▃▁▄█▄▅▁▁▂ + ▁▁▇ ▁▂ ▂▅▅▆ ▅ + ▃▁▇ ▁ ▅▆▅▂▆▁ + ▃▁▁█▂▇▁▅▅▇▂▁ + █▅▅▂▄▅▂▂▄▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_17.txt b/codex-rs/tui2/frames/hbars/frame_17.txt new file mode 100644 index 00000000000..93817e2eadd --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_17.txt @@ -0,0 +1,17 @@ + + ▆▄▇▇▇▄▄▁▆▂ + ▂▄▇▇▁▁▁▇▄▇▁▆█▃▆ + ▆▇▃▅▂▄▄▂▇▆█▁▁▁▂█▃ + ▅▁▅▂▅▁█▂▂ █▁▄▃▁▁▁▄▃ + ▁▅▂ ▁ ▁█▅▃▄▃▅▅ ▆ + ▂▄▇▆▅▂ ▆▅▅▁ ▅▆▄▅▁▅▅ + ▇ ▄▅▁ ▆▅▅▁▂ ▇█▁▁▅▄▁ + ▆█▄▁ ▅▅▁▁▅▁▆▁▁▄▄▇ + ▆▅▇▅▃▄▄▇▁▃▂▁▃▃▅▃▁▁▁ ▁ + ▁█▂▄▂▂▁▅▇▃▅▁▁▃ ▃▇█▇▃ + ▃▃▃▅███▇▇▇▇▃▂▁▇▅▅▃ ▃█ + ▃▁▁▂▇ ▂▅▁▁▂ ▄ + ▂▃▃ ▇▃▂ ▆█▆▅▃▂▅ + ▆ ▁▇▇▄▃▇▂▂▄▇▅▁█ + ▂▇▄▅▂▆▇▁▇▂▇▇ + ▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_18.txt b/codex-rs/tui2/frames/hbars/frame_18.txt new file mode 100644 index 00000000000..03d2c5e94b8 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_18.txt @@ -0,0 +1,17 @@ + + ▂▄▄▇▄▄▇▄▁▆▂ + ▂▇█▂▄▆▇▇▁▂▂▆█▇▄▄▂ + ▂▅▅▁▇█▇▄█▆█▅▂█▇▅▅▃ ▇ + ▆▁▁▇▅▆▃█▇ █▃▂▂▅▄▁▃▄▃▃▃▆ + ▂▃▅▅ ▅▁ ▂█▇▂▃▅▁▅▁▂▃▄▃▃▂ + ▅▃▁ ▆▆▁▅▂▂▂▅▆▇▆▁█▆▅▃▅▂▅▁ + ▁▁▃▅▅ ▇▅▇▅▁█▂▅█▇▁▄▁▄▆ + █▃▆▁▁ ▃▃█▁▁▄▃ ▁▄▁█ + ▃██ ▆▃▄▄▄▄▄▇▁▁▆▁▇▅▃▆ ▁▇▁▂ + ▂▃▃▁▇▃▇ ▁▁▅▂▂▃▂▁▁▁▂▄▁▂▃ ▆ + ▁▅▁▃▂██▇▇▇▇▇▂ ▃▂▂▅▁▅▇▅▂▆▂ + ▃▃ ▆▃▆ ▂▂▇█▅▇▂█ + ▃▃▇▇▃▃▅ ▂▇▃▂▅▃▆▂ + █▁▅▇▇▇▇▄▄▄▃█▇▂▅▂▆▇ + █▁▁▂▅▂▂▆▅▇█▄▇█ + ▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_19.txt b/codex-rs/tui2/frames/hbars/frame_19.txt new file mode 100644 index 00000000000..f8267761700 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_19.txt @@ -0,0 +1,17 @@ + + ▂▂▄▄▇▁▇▄▄▁▅▂ + ▂▇▁▁▁▃▅▄▄▄▂▇▂▆▁▇▆▆ + ▆▇▄▄▅█▁▇▇▂ ▂▇▅▆▂▇▄▃ + ▅▄▁▇▆▃▂ ▂▄▄▇▆▃▁▇▆ + ▁▁▅▂▅▂ ▆▁▃█▅█▁▃ ▁▃▆ + ▁▃▅▇█ ▂▁▇▁▅ ▅▅ █▅▇▁ + ▃▄▁▁█ ▅▇▃▅▁▇▆▂ ▅▅▅▇▁ + ▁▁▁▁ ▁▄█▃▁█▆ ▁ ▁▁▁ + ▁▇▁▃▆▄▄▄▄▄▄▁▁▁▁▇▇▄▇▁▄ ▃ █▁▁ + ▃▄▅▆▁▄▇▅▃▇▇▃▆▂▆ ▃▂ ▅▃▂▃▆▅▅▁▂ + █▃█▅▇▁█████▇▇█ ▃▃▂▅▁▅▅ ▅▅█ + ▃▃▃▇▂▃ ▂▅▅▂▅▅█ + █ ▂▃▁▁▇▆ ▆▄▄▅▅▁▄▂ + ██▅▁▇▅▁▇▂▂▄▄▅▇█ ▄▇▁█ + █▁▅▂▅▆█▂▆▅▃▇▆▃ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_2.txt b/codex-rs/tui2/frames/hbars/frame_2.txt new file mode 100644 index 00000000000..d4efa4def0e --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_2.txt @@ -0,0 +1,17 @@ + + ▂▅▂▅▄▇▄▄▄▇▆▂ + ▂▇▆▅▇▄▇▁▁▁▄▁▁▄▂█▁▇▇▂ + ▆▁▇▁▃▇▇▁▇▂▂ ▂█▇▄▇▅▁▁▄▁▆ + ▂▁▅▂▅▇█▆▆▂ ▆▆▃▁▅▁▆ + ▆▃█▅▇▃▁▃▂▃▃▂ ▃▆▁▃▁▆ + █▅▇█▂ ▁▇▃▇▅▃▇ ▃▃▁▇▁ + ▆▁█▇▁ ▃▅▁▄▁▂▁▆ ▅█▁█▁ + ▁█ ▃▂ ▆▁▇▁▁▄▇ ▁█▅▅▁ + ▇██▄▁ ▄▁▃▃▂▁█▅▁▇▇▇▇▇▇▇▇▆▁▂▁▁▇ + ▂▄█▁ ▅▁▁█ ▅▇ ▁▄▃▂▂▂▂▅▅▂▂▁▇▅ ▁ + ▃▃▃ ▇ ▇▃▅▅▁█ ▂█▇▇▇▇▇▇▇█▁▆▇▁▁ + █▃▂▅▅▆ ▂ ▆▇▄▅▆▇ + ▅▃▂▂▁▇▆ ▆▄▂▇ ▂▁█ + ▂▁▃▄▂▂█▇▇▅▄▄▄▄▅▄▇▂▂▁▁▇ + ▂▇▇▇▇▁▁▂▂▂▂▁▄▅▇█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_20.txt b/codex-rs/tui2/frames/hbars/frame_20.txt new file mode 100644 index 00000000000..30c29f51c9b --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_20.txt @@ -0,0 +1,17 @@ + + ▂▂▇▄▁▁▄▇▄▆▂▂ + ▆▄▄█▄▆▂▆▄▄▄▁▁▂▃▇▃▇▆ + ▆▆█▂▇▇█▁▇█ ▂█▃█▃▇▇▁▇▂ + ▂▁█▂▇▁▇ ▂▆▇▁▃▅▇▃▃ + ▂▁ ▆▁▇ ▂▅ ▂▁▁▃▇▃▁▁▃ + ▅▂▆▁█ ▇▇█▄▁█▅ █▃▁▁▃ + ▄▁▄▇▁ ▆▇ ▅▇▇▃ ▆▃▁▅ + ▁▂▃▅▂ ██▄▅▁█▆ ▁▁█▁ + ▁▅▁▁▄▆▄▄▄▄▄▄▄▄▂▆█▄▃▃▃▃▇ ▅ ▁▃ + █▁▃▁▂█ ▅▂▂▂▂▂▂▁▃ ▇▃▃█▁▄▃ ▆▅ ▁▁ + ▃▆▃▃████████▇█ ▃▆▄▁▁▆▁ ▅▃ + ▇ ▃▇▅▆ ▂▅▇▅▁▅ + ▃▂▃▇▆▁▂ ▆▄▇▂▄▇█ + ▃▆▆▅█▁▇▃▄▄▆▅█▁█▂▆▅▇█ + ▂▇▇▁▂ ▄▂▂▂▂▁▇██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_21.txt b/codex-rs/tui2/frames/hbars/frame_21.txt new file mode 100644 index 00000000000..b6a6c2c109c --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_21.txt @@ -0,0 +1,17 @@ + + ▂▂▆▄▇▄▄▇▄▇▆▂▂ + ▆▇▇▂▂▂▂▇▁▄▁▇▄▂▂▆▇▇▂ + ▄▇▃▂▇▇▃█▂ ▂▃▁▇▇▂▂▇▆ + ▆▇▁▄██ ▂▆▄▇▆▄▇▆ + ▅▆▅▇▂ ▆▁▅▂▃ ▃▃▂▃▆ + ▅█▅▁ ▆▇▇▂▅▅ ▃▃▃▆ + ▁█▁▂ ▂ ▅▂▆▅█ ▃▄▃█ + ▂▃ ▃▄ █▁▆ ▁ ▁ + ▁ ▁ ▆▇▄▄▄▄▄▇▇▇ ▃▁▆█▁▆ ▄▅ ▁ + ▁ ▁▃ ▁▂ ▂▆▁ ▃█▂▇▁▆ ▅█▅▅ + ▄ █▅ ▂▂███ █▂ █▇▂▆▄ ▅▂ ▆ + █▇ ▅▄▂ ▂▇█ ▅ + ▇▂ ▃▆▂ ▂▅▄▇█ ▆▂ + ▇▄▂ ▇█▇▇▅▁▁▄▅▄▇██ ▆▂ + ▇▇▇▄▇▂▂▂▂▆▂▄▇▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_22.txt b/codex-rs/tui2/frames/hbars/frame_22.txt new file mode 100644 index 00000000000..38195cd38b3 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_22.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▇▄▄▇▄▇▆▂ + ▂▇▅▇▁▅▂▅▁▅▃▁▁▄▁▇▅▇▇▂ + ▆▄▇▄▁▃▇▇▂ ▂█▇▃▇▇▇▂▂▇▆ + ▄▇▁▇▇█ ▆▆▁▁▁▂▇▃ + ▃▁▁▅█ ▆▂▅▁▄▁▅▅▄█▁ + ▃▁▇▅▂ ▆▁▅▁▄▃▅▂ ▃▁▃▄▁ + ▁▇▇▃▅ ▁▇▁▃▇▅█ ▁▁▅▁ + ▁▁▄▁▂ ▁▃▁▁▃▃ ▁▃▁█▁ + ▁▁▆█▆ ▇▁▄▄▄▄▄▄▇▇▂▃▇▁▂ ▃▆ ▂▁▄█ + ▃▁▄ ▇▂▃▃▆ ▅▆█▁▅▅▁ ▃▇▄▂█▁▆▆ ▅▃▄ + ▃▃▃▂▁ █████▇▇█▂ ▂▅▇▇▂▅▅▅█▆▂ + ▃▇▃▂▃▆ ▂▇▆█▁▆█ + ██▇▄ ▇▇▂ ▂▅▇▆█▇▂▅ + █▅▇▇▆█▇▁▄▄▇▄▄▅▄▇███▆▇ + █▁█▁▂▄▂▂▂▆▄▂▅▄▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_23.txt b/codex-rs/tui2/frames/hbars/frame_23.txt new file mode 100644 index 00000000000..a81cac3ef20 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_23.txt @@ -0,0 +1,17 @@ + + ▂▆▇▂▄▇▇▇▇▄▇▂▂ + ▂▄▁▇▁▃▂▆▂▄▄▆▁▂▁▇▁▇▂ + ▆█▇▁█▆▄▇▂ █▁▅▇▁▃▄▁▁▆ + ▅▄▁▇▄▅▂ ▂▄▄▃▁▁▄▃ + ▅▅▁▂▅█ ▆▅▆▆█▇▅▇▃▁▃ + ▆▃▁█▅▂ ▂▇▇▇██▅▇█▁▁▃▁▆ + ▂▁▁█▁ ▅▅▅▅ ▂▁▂ █ ▁█▇ + ▁▅▁ ▁ ▁█▁▆▂▃▆ ▁ ▇▅▁ + ▃█▃ ▁▇▁▄▄▄▄▄▄▄▄▁▂▇▁▂█▃▂ ▁ ▁▁▁ + ▁▃▃▂▁█▄█ ▅▂▃ ▃▁▂▃▆▁ ▃▂▁ + ▅█▁ ▁ ▂███████ █▂▅▄▄▁▂▅▃▅▂ + ▆▁▁▂▁▅ ▆▅▁▃▃▅▂ + ▃ ▁▆█▅▂▂ ▂▅█▄▇▅▇▅ + ▃▄▃▇▄▇▇▃▁▄▄▂▇▇█▆▂▇▇▂ + █▁ ▇▄▃▁▂▂▇▂▂▇▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_24.txt b/codex-rs/tui2/frames/hbars/frame_24.txt new file mode 100644 index 00000000000..791f93b5914 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_24.txt @@ -0,0 +1,17 @@ + + ▂▁▂▄▇▇▇▄▄▆▂ + ▆█▂▅█▁▂▂▁▄▄▇▆▂█▇ + ▆██▄▃▅▇█▁ ▅▂▅▇▁▇▇▆ ▃ + ▆█▅▅▆▅▁▄▆▃▂▁▅▂ ▂▇▃▁▃▂█ + ▆ ▅▅▃▅▆ ▂██▅▃▂▁▂▂▃▁▆█ + ▁▆▃▇▃▂ ▇▆▆▅ ▅▁▄ ▅█▃ + ▁ ▁▁ ▆▅▂▆▅█▅▃▃▁▃▃▁▃ + ▁ ▃▁▁ ▁▆▄▅▃▂▁▁▂▅▅▂▁▁ + ▁▅ ▁▄▄▄▅▇▇▁▁▁▇▇▂▇▆▂▁ ▁ ▁▁ + ▂ ▅ ▇▁▅▆▆▄▄▆▆▁▅▃▃▇▇▁ ▁▇▁ + ▃ █▃▁▃▇██████▂ ▃▆▅▇▃▁▄▅▆▂ + ▃ ▃▆▇▃ ▁ █▅▄▅ + ▃▅▇▃▂▁▅ ▂▄█▂▅▅▂▇ + ▅▆▇▆▃▃█▁▁▅▄▄▄▁▃▂▅▂ + ▇▅▂▇▇▄▃▂▂▄▄▇▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_25.txt b/codex-rs/tui2/frames/hbars/frame_25.txt new file mode 100644 index 00000000000..565fdb82ead --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_25.txt @@ -0,0 +1,17 @@ + + ▂▇▄▇▄▇▇▇▇▂ + ▆ ▇▆▇▂▂▄▃▁▇▁▁▆ + ▅▁▅▅▆▆█▅▁▇▃▄▃▁▁▁▂ + ▁▇▇▁ ▅ ▄▁ █▃▁▆▇▃▅ + ▆▂▂▃▆▅█▃▄▅▂▁█ ▁▅▅▃▅▇ + ▃▆▁▁▁▂▇▇▅▃▁▅ ▅▁▆▁▅▅▁▆ + ▁▅▁▇▁ ▁▂▂▅▃▂▅▁▃▅▂█▁▄▁ + ▁▁▁▅▁▅█ ▁▂▄▃▅▁▃▂▁▄▁▁▃ + ▃▇▃▁█▁▂▂▄▄▄▄▇▁▁▃▃▁▇▇▁▃ + ▅▆█▁▅▁▆▁▂▁▁▄▇▂▁▃▇▄▅▃▁ + ▃▁▇▅▃▁▂██████▇▅▁▃▁▅▁▂ + ▁▂█▁▄▇▄▃▆▆▇▅▂▅▆▅▅▁▅ + ▃▂▃▁▂▃▂▇▄▅▆▃▅▂▁▁▅ + ▃▅▄▃▃▂▇▄▁▇▇ ▇▁▇ + ▁▆█▇▃▇▆▂▇▁▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_26.txt b/codex-rs/tui2/frames/hbars/frame_26.txt new file mode 100644 index 00000000000..e37d671dc4b --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_26.txt @@ -0,0 +1,17 @@ + + ▂▄▇▇▇█▇▆ + ▅▇▁▄▅▃▁▇▄▁▆ + ▅▇██▁▄▃▁▃██▃▆ + ▄▇█▆▁▁▁▂▁▁▂▇▅▃ + ▁▂▆▃▁▅▁█▇▃▅▄▁▃▄ + ▃ █▁▅▅▅█▇▂▃█▃▃▅ + ▁ ▇█▄▁▁▅▁▁▄▄█▇ + ▅▆ ▃▁▁▁▃▃▄▁▁▅▁▁ + ▁ ▅▃▇▄▇▁▇▄▄▅▃▁▁ + ▅▂▁▅▆▁▂▂▆▇▃▃▇▁ + ▁▂█▅▁▇█▃▆▅▂▇▃▁▁ + ▃▂▁▁▅▃ ▅▅█▁▁▂ + ▇ ▇▁▃ ▃▂▅▁▅▆ + ▁█▇▃▃█▇▂▇▇▄ + ▃▅▅▃▇▁▂▃▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_27.txt b/codex-rs/tui2/frames/hbars/frame_27.txt new file mode 100644 index 00000000000..d3dbefa9754 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_27.txt @@ -0,0 +1,17 @@ + + ▄▇▇▇▇▆ + ▁▄▅▅▁▅▃ + ▄█▂ ▇▁▅█▁ + ▁▁▁▃▁▁▄▁▁ + ▁ ▂▁▂▆█▄ + ▁▂▅▂█▂▁▁▁▂ + ▄ █▁▁▂▇▁▁ + ▁ ▂▁▁▁▄█▁▁ + ▁▅ ▅ ▁▇▁▁▁▁ + █▄ ▅█▂▁▂▇▁▁ + ▁▆ ▂▃▇▇▇▅█ + ▁▁▇▇▁▁▂▅▁ + ▁▄▄▇▄▆▅▁▃ + ▃▂▂▁▃▄▅▅ + █▃█ ▃▃▅ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_28.txt b/codex-rs/tui2/frames/hbars/frame_28.txt new file mode 100644 index 00000000000..0ae0f54e0b0 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_28.txt @@ -0,0 +1,17 @@ + + ▅▇▇▄ + ▁▇▂▁▂ + ▄ ▄▁▂ + ▁▆▂▇▁ + ▁ ▁▁ + ▁ ▆█▁ + ▃▁▁ ▂▁ + ▁▆██▁ + ▅▃ ▂▁ + ▁▁▅▅▁▁ + ▁ ▂▁ + ▁▄▇▄▁ + ▁▄▇▁▁ + ▇▂▂▅▁ + ▁ ▅▇▁ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_29.txt b/codex-rs/tui2/frames/hbars/frame_29.txt new file mode 100644 index 00000000000..d333f278dce --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_29.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▆ + ▆▁▇▅ ▇▃ + ▃▆▁▁ ██▁ + ▁▁▁▁▃▃▆▅ + ▃ ▁█▁ ▁ + ▁▆▁▁▁ ▂▂ + ▁▃▃▁▁▁ ▃ + ▁▁▄▁▁ + ▁▁▁▇▁ ▂ + ▂▁▄▁▁ ▂ + ▇▁▁▅▁▃ ▄ + ▁▅▁▁▁ ▂▁ + ▁▁▁▁▂▁▂▁ + ▁▄▁▇▅█▁█ + ▁▁▇ ▅▆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_3.txt b/codex-rs/tui2/frames/hbars/frame_3.txt new file mode 100644 index 00000000000..5d0b07202ae --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_3.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▇▇▄▄▄▄▆▂ + ▂▇▆▅▃▁▁▇▁█▄▄▁▄▂▃▁▇▇▂ + ▆▄▅▇▄▁▇██▂ ▂█▁ ▇▇▇▅▃▁▆ + ▅▇▅▃▁▄▆▇▂ ▂▄▇ ▁▁▃ + █▅▇▁▅▃▅▁▁▇▃▂ ▃▃▃▁▁▂ + ▅▁▃▃▅ ▅▆▁▃▄▃▃ ▁ ▃▃▁ + ▄▇ ▁ █▇▃▁▂█▁▆ ▅▇▅▁▆ + ▆▆▁▆▁ ▆▆▁█▅▃▁ ▁▂▁▄▁ + ▆██▁ ▄▁▇▇▁▁▇▆▁▄▇▇▂▂▂▇▇▂▁▃█▄▁ + ▆▃▅▃▆ ▅▅▇▅▄▄▁ ▁▇▁▂▂▆ ▄▅▆▄▃▂▁▃▁▂ + █▃▁ ▁ █▁▁▇▆█ █▇▇▇███▇▇▆▅▅▅▆▅ + █▇▁ ▃▇ ▂ ▅▃▇▃▅▅ + ▄▃▅▅▇▃▆ ▆▇▄▅█▆▁█ + ▄▃▇▆▂▇▇▃▄▄▄▄▆▅▄▇▄▂▂▇█ + ▂▇▇▇▂▅▁▂▂▂▂▁▄▅▃█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_30.txt b/codex-rs/tui2/frames/hbars/frame_30.txt new file mode 100644 index 00000000000..7ceb36d37ac --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_30.txt @@ -0,0 +1,17 @@ + + ▂▄█▇▇▇▆ + ▂▅▄▁▁▃▇█▄▃ + ██▃▁▆▃▃▁▇▆▃ + ▁▁▃▁▅▁▂▃▁▃ ▃▆ + ▄▇▁▁▂▃▁▆▁▆ ▁ + ▁▇▁▃▇ ▇▁▁▁▁▅▁ + ▃▅▁▁▁▃▂ ▃▅▃▁ ▄▂ + █▁ █▇▄▁▆▁█▁▆ ▂ + █▁▂█▃▁▄▁▃▅▁▆ ▂ + █▁▁▃▁▂▂▁▁ ▇▆ ▂ + ▃▁▁▄▃▂▇▁▁▁▇▁▁ + ▁▇▅▅▆▅▇▃▁▁█ ▁ + ▃▁▃▁▁▅▇▁▁▆▁▅ + ▆▃▆▇▄▃▅▁▆▅█ + ▃▆▅▁▃▂▂▄█ + ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_31.txt b/codex-rs/tui2/frames/hbars/frame_31.txt new file mode 100644 index 00000000000..419be30ed96 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_31.txt @@ -0,0 +1,17 @@ + + ▂▅▅▇▇▇▇▄▆ + ▂▇▇▄▇▆▂█▇▃█▇▆ + ▆▁▃▅▅▆▄▂▂▅█▁▂▇▃ + ▆▁▅▇▂▂▇▄▂▆▃▃▁▃▁▁▃ + ▃▁▁▁▁▇▆▇▃▂▆█▃▁▅▂▂ + ▄▁▃▂▁▂▁██▃▄▇▆▅▅▁▆█▁ + ▅▁▁ ▁▃▇▁▃▅▄▅▄▇▁▇▁▅▁ + ▃▃▆ ▁▁█▁▃ ▁▃██▁▁▃▅▁ + ▁▃▆▂▁█▃▁▁▁▅▇▁▁▁▂ ▁ + █▁▁▁▅▅▁▁▂▂▂▃▂▁▃▁▁ ▇ + ▃▇▂▅▇▁▆ ▅▇▇▇▂▅▁█▁▂ + ▁▁▁▃▃▃▆ ▂▄▇ ▁ + ▄▁▇▄▂ ▆ ▅ ▁▇▂▅▂ + ▅▁▃▁▅▂▁▂▆▁█▆▁▂ + ██▄▁▂▇▇▅▅▄▇ + ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_32.txt b/codex-rs/tui2/frames/hbars/frame_32.txt new file mode 100644 index 00000000000..1234a419b0c --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_32.txt @@ -0,0 +1,17 @@ + + ▆▇▇▇▇▄▄▁▆▂ + ▄▇▁▇▇▁▃▁▁▇▇▁▁▆ + ▅▃▇▄▃▆▇▃▅▆▂▇▁▇▃█▇▆ + █▅▅▁▂▆▂▄▆ ▆ ▆▂▃▁▇▆ ▆ + ▅▅▁▆▁▇▃█▇▁▄ █▂▆▁▃▅▃▇▃▆ + ▃▅▃▃▆▃▇▁▂▅▁▁▇▂ ▁▇▅ ▇ + ▇▁█▄▆▂█▃▁▁▆▆▁▆▄▂▅▂▅▅▁█▂ + ▁▁▁▆▂ █▂▁▁█▅█▁▄▆▂█▃▁██▂ + ▃▁▁▆▂ █▂▁█▂▂▁▇▇▇▁▁▁▄▁█▄▂ + ███▂▂▁▇▃█▃▂▂▂▂▂▁▁▇▁▄▁▆▂ + ▆▃▇▁▁▅ ▁▇██▇▇▇▃▄▅▅▅▅▅ + ▃▆▁▃▃▃ ▆▃▁▅█▁ + █▇▇▁▁▁▇▂ ▂▅▂▁▅ ▆ + ▃▆▃█▁▇▁▄▄▇▂▂▇█▂▅ + ▁▄▄▂▂▆▇▅▇▂██ + ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_33.txt b/codex-rs/tui2/frames/hbars/frame_33.txt new file mode 100644 index 00000000000..780eb104ef3 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_33.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▇▄▇▄▁▅▂ + ▂▇▇▁▁▃▇▄▁▅▁▆▁▇▁▇▅ + ▂▅▃▁▇▇▇▆▄█▂ █▇▅▁▁▇█▆ + ▆▃▅▁▅▂▁▆▂ ▇▁▁▃▂▇ + ▂▅▁▅▂▁▁▅▃▃▃ ▁▇▃▃▃ + ▅▅▆ ▃▂▃▃▂▃▆ ▅▃▅▂▃ + ▁▁▇ ▃▆▇▁▂▁▃ ▁▇▁▁ + ▇▁▆▆ ▆██▃▅▄▆ ▃█▁▇ + ▁▁▁ ▁ ▆▂ ▅▇▆▆▆▄▄▄▇▁▁▇▅▂▁▃▁ + ▃▅▁██▅▅▆▅▂▁▁▂▂▂▂▂▁▁▇▃▃▃▁▁▅ + ▃▄▃▆ ▁▅▁▅ ▅ ████▇▇▇ ▁▁▆▅▃▂ + ▃▇▃▇▆█▆ ▅▅▄▅▄▅ + ▇▃█▇▆▇▄▆ ▂▇▁▆▅▇▃█ + ▃▁█▇▃▇▁▆▄▇▂█▄▆▃▇▁▇ + █▄▄▇▆▂▂▂▃▇▇▄▆█ + ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_34.txt b/codex-rs/tui2/frames/hbars/frame_34.txt new file mode 100644 index 00000000000..4bf69e69eb4 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_34.txt @@ -0,0 +1,17 @@ + + ▂▆▄▇▇▇▇▄▁▆▂ + ▂▇▇▇▅▃▄▁█▁▇▇▄▇▇▄▆▄▂ + ▂▅▅▆▁▇█▅▁▇▂ ▂▃▃▃▆█▇▇▆ + ▆▅▂▁▇▂▅▆▂ ▇▁▆▇▃▃ + ▅▅▁▃▆▅▁ ▁█ ▆ ▃▃▇▃▃ + ▆▅▃▁▂▅ ▃▃█▁▃▇▃ ▃▆▃▁▃ + ▁▇▃▁▂ █▃▂▇▁▂▃ ▁▄▁▂ + ▃▁▄█▅ ▁▇▂▁▅█▁ ▅ ▂▃ + ▆█▅▅█ ▅▃▅▅█▂▄▄▇▇▇▇▇▇▁▂▂█ ▁▃▂ + ▁▁▃▆▅▆▆▃█▅▅█▁▁▁▂▁▅▂▂▂▆▅▂▁▁▂▁▃ + ▅▂▃▆ ▁▃▁▇▃ █▇▇▂▂▂▂▃ ▆▅▆▃▁█ + ▅▆▃▄▄▃▂ ▅▄▅▁▅█ + ▃▇▂▁▇▃▄▆ ▂▇▄█▂▅▆▂ + ▆▃▅▁█▁▇▆▄▆▇▄▇▄▇▂▇▇▅█ + █▁▃▄▄▂▂▂▂▂▄▄▇▄▃ + ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_35.txt b/codex-rs/tui2/frames/hbars/frame_35.txt new file mode 100644 index 00000000000..86dde2ad341 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_35.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▄▇▇▇▄▇▆▂ + ▆▇▄▇▅▁▇▇▇█▁▇▁▅█▁▇▄▂ + ▂▅▅▁▄▇▇▁▃▇█▂ ▂█▇▄▇▅█▁▇▆ + ▆▅▅▇▅▆▄▆▂ ▇▇▂▃▁▂ + ▅▅▁▁▅▅▃ █▃▇▆ ▁▃▇▁▆ + ▄▅▄▅▇▂ █▁▂█▅▅▇▂ ▁▃▃▁ + ▃ ▅▅ ▃▃ █▇▂▅ ▁▃▃▃ + ▁▁▁▅ ▅▃▃▄▅█ ▅ █▅ + ▅ ▆▂ ▆▁▁▅▄█▅▁▄▄▂▄▂▄▇▁▁ ▁▁▄▇ + ▁▆▃▄█ ▅▅▄█▇▇▅▁▄ ▃▆▂▂▂▂▅█▁▅█ ▁█ + ▅▂▃▆█▅▃▂▁▅▃▂ ▂█▃▃▃▃▇▃▃▇▅▅▂▆▁ + █▅▃▃▅▁ ▂▅▇ ▆▃ + ▃▄▂▃▄▆▅▆ ▆▄▅▂▇▇█ + █▃▂█▁▇▁▆▅▁▁▇▁▄▅▇█▂▅▄█ + █▃▁▄▄▂▂▅▂▂▇▇▇█▃ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_36.txt b/codex-rs/tui2/frames/hbars/frame_36.txt new file mode 100644 index 00000000000..bccadcf7b78 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_36.txt @@ -0,0 +1,17 @@ + + ▂▂▇▄▇▇▇▇▄▇▆▂ + ▂▄██▃▇▅▄▅▇▃▇▄▇▇█▇▇▇▂ + ▆▃█▃▆▇▇▆▇█▂▂ ▂█▇▁▇▇▁▆▇▁▆ + ▂▁█▇▇▂▂▂▂ █▇▁▃█▁ + ▆▅▂▄▃█▁▃▂▃▆▅ ▃▃▅▇ + ▅ ▆▅▂ ▇▂ █▁▅▆ █▃▃▇ + ▁ ▄ ▂ ▃ █▃▃ ▁▃▁▆ + ▁▁▃▁ ▂▃ ▂▅▅ ▃▁ ▁ + ▁██▁ ▅▅ ▆▇██▆▇▇▂▇▇▇▇▇▂ ▁▃▁▁ + ▂▇▁▃ ▆▁▂▂▅▁▅ ▂▁▂▂▂▂▂▂▂▁▁ ▃▅█▂ + █ ▇▁▃ ▇▂▇▅▃ █▇▇▃▃▃▃▃█ ▁▆█▅ + █▆█▃▄▅ ▆▅▁█▅ + ▃▅▁▁▇▁▆ ▆▇▇█▅▆▂ + ▇▆▂█▇▇▄▃▄▁▂▇▁▄█▆█▆▄▇█ + ▂▇▇▅▁▂▆▆▂▆▆▇▇▇▇█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_4.txt b/codex-rs/tui2/frames/hbars/frame_4.txt new file mode 100644 index 00000000000..5867215a96d --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_4.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▇▄▄▇▄▇▆▂ + ▂▆█▇▇▁▅ ▁▃▄▅▇▄▁█▅▇▆▂ + ▆▇▄▁▁▁▇▇█▂ ▂▃▁▃▃▇▁▅▃▃▂ + ▅▅▁▆▁█▅▇▆ █▁▇▇▃▇▃ + ▅▁▅▂▁▄▁▃▄▃▁▆ ▃█▃▁▇▃ + ▅▁▁▃▁ █▂▁▁▅▇▃ █ ▃▄▁▆ + █▃▁▄▂ █▇▇▃▇█▁▆ ▆ █▄▁ + ▁▅▃ ▆▁▅▇▃▁▁ ▁▁▁ + ▂▁ ▁▂ ▄▁▅█▃▅▅▇▁▇▇▇▂▂▂▂▆▆ ▁▁▇ + ▁▃▅ ▁ ▆▅▅▅▆▂▁▂▄▃▁▆▅▂▆▅▅▃▄▁▅▅█▁ + ▂▁▄█▃▆▃▃▆▃▅ █▇▇▇▇▇▇▇█▃▆▁▁▁▂ + ▆▁▃█▁▂▂ ▆▃▄▅▇▇█ + ▃▃▆▅▃ ▆▂ ▂▅▃▆▇▄▁▅ + ▁▁▁▄▂▇▇▃▄▄▄▄▆▇▄▇▇▃▁▇▂ + ▇▃▃▁▅▁▂▂▂▂▂▄▆▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_5.txt b/codex-rs/tui2/frames/hbars/frame_5.txt new file mode 100644 index 00000000000..d0cd750b8a7 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_5.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▄▇▄▄▄▇▆▂ + ▂▇█▇▅▇▅▁▁▇▁▄▇▄▂▁█▇▆ + ▇▂▅▃▇▁▇█▂ █▃▄▁▇▃ ▆▇▆ + ▆▁▅▁▅▁▅▄▆ ▆▃▁▁▁▁▂ + ▆▃▅▇▅▃▂▇▁▂▃▆ ▂ ▃▁▃▁▂ + ▁▁▁▁▅ ▃▂▁▃▄▅▁▂ ▇▅▁▁ + ▁▅▁ ▇ ▂▃▂▁▃█▁▆ ██▁▇▁▆ + ▁▅▁▂█ ▆▂▄▁▅▂▁ ▇▁▂▁ + ▁▆▁▃▇ ▅▂▁█▆▁▇▄▂▁▇▁▂▂▂▇▆▂█▁▅▁ + ▂▃▁▃▆▆▅▇▇█ ▁█ ▅▅▃▄▃▆▄▅▆▅▁▅▇▁▂ + ▃▆▁▁▆▁▆▃▃▅▇▂ █▇▇█████▆▁▄▁▃▅ + ▃▃▇▅▃▃ ▂ ▅▂▅▇▆▃ + ▃▅▇▃▁▄▇▂ ▂▄▇▆▇█▄▇ + ▃▁▇▁▆▇▇▃▄▁▄▂▆▇▄▇▃▁▄▇ + ▃▆▇▂▇█▂▂▂▆▂▆▇▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_6.txt b/codex-rs/tui2/frames/hbars/frame_6.txt new file mode 100644 index 00000000000..2fde73afab1 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_6.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▄▇▄▄▇▇▂▂ + ▆▃▄▅▇▁▄▇▇▁▇▇▄▂▆▇▇▂ + ▆▆▆▁▁▁▇█ ▂█▁▅▄▇▇▇▅▃▂ + ▅▆▁▇▁▇▂▄▆ ▃▇▁▁▁▁▆ + █▄▁▁▁▇▃▃▇▇▃ ▅▃▃▁▃▆ + ▄▆▅▁▅▂▃▇▄▁▃▂▁▆ ▅▄▃▅▁ + ▂ ▇▁▁ ▃▃▁▅▃▃ ▃▇▁▂▁ + ▁ ▁▁▁ ▂▁▄▁▅▁▁ ▁▇▃▁▁ + ▂▅▁▆▁ ▅█▁▁▇▄▅▇▂▁▇▁▂▂▇▇▂▅▃▅▁ + ▁▁▃▆▅▂▃▂▄▇ ▁▅▄▇▆▁▁▆▄▄▄▃▁▆▁▃ + ▅ ▁ ▅▁▁▁▄▅█ █▇████▇█▂▃▃▅▁ + ▆ ▄ ▅▆▂ ▆▅▂▅▅▁▂ + ▅▂▃▂▇▇▆ ▂▇▃▂▅▇▆▇ + ▂█▁▇▆█▇ ▄▁▂▂▆▇▇▇ ▅▄█ + ▃▆▄▇▇▆▂▂▂▂▂▆▇▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_7.txt b/codex-rs/tui2/frames/hbars/frame_7.txt new file mode 100644 index 00000000000..f9b4ed92190 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_7.txt @@ -0,0 +1,17 @@ + + ▂▅▁▄▇▇▄▄▇▆▂ + ▆▇▂▇▃▁▇▇▇▄▄▃▄ ▂▇▆ + ▇ ▆▁▁▁▇▂ ▂▃▁▂ ▇▅▂▇▆ + ▃▃▁▁▁▁▄▇ ▃▁▃▇▁▅▆ + ▂█▁▇▁▁▃▁▅▃▆ ▅ ▇▁▁▃ + ▁ ▄▁▁█ ▂█▃▄▁▃ ▆▆▁▁▁ + ▂▃█▁ ▃▂▁▃▁▃▁ ▇▇▃▁▁ + ▂█▅▁▁ ▄▇▅▁▇▁▂ █ ▁▁▁ + ▅█▁▇ ▆█▂▅▁▃▅▁▁▄▄▂▁▁▄▄▇▃▁▁ + ▁▆█▁▁▄▅▁▁▅█▁▅▃▁▁▃▃▆▄▆▄▃▁▃▁ + ▃ ▄▃▅▇▁▇▂▅ ▇▇▇▇▇▇██▂▅▁▁ + █▂ ▅▂▅▂▂ ▆█▆▅▄▅█ + █▄▆▇▃▃▃▂ ▆▃▁▆▇▅▇█ + ▃▂█▃▁▇▃▄▁▂▂▂▇▇▁▇▇▅ + █▁▇█▁▅▁▂▂▆▂▄▇▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_8.txt b/codex-rs/tui2/frames/hbars/frame_8.txt new file mode 100644 index 00000000000..44c448de8a3 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_8.txt @@ -0,0 +1,17 @@ + + ▂▂▇▂▄▇▇▄▇▆▂ + ▆█▇▅▁▁▁▇▄▄ ▁ █▇ + ▂▁▄▆▁▅▅▇ ▆█▁▅▁▇▃▇▃▆ + ▂▇▃▇▅▁▆▇▁ █ ▄▃▁▁▃ + █ ▅▁▅▆▃▁▅▃ ▅█▃▁▁▆ + ▁█▆▁▃▃▃▂▃▁▂▇ ▃▇▁▁▁ + ▁▇▁▁▁ █▂▄▃▁▂▁ ▁▃▁▅▁▆ + ▃▆▁▁▅ ▁▇▃▅▁▆▇▅ ▅▂▅▁▁ + ▃ ▃▁▁▂▃ ▅▇▄▁▁▅▇▆▇▆▆▃▁▁▁ + ▃▇▅▄▂▁█▆▁▆▁▁▄▁▄▂▂▁▁▄█ + ▅▅▃▃▁▃▁▁▁▅█▃▇▃▇█▃▇█▅▃▁ + █▆█▁▃▁▂▂ ▅▆▅▅▅█ + █▃▂▇▃▃▃▂ ▂▅▃▂▁▇▅▇ + ▅▅█▁▆▇ ▄▄▇▇▄▅▄▆▁▂ + ▁▁▂▇▇▃▁▂▆▇▅▄▂ + ▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_9.txt b/codex-rs/tui2/frames/hbars/frame_9.txt new file mode 100644 index 00000000000..a18a8a231c3 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_9.txt @@ -0,0 +1,17 @@ + + ▅▄▃▇▅▇▇▄▆ + ▅▇▂▅▁▁▇█▄ ▂▆▃▂ + ▅ ▆▁▁▅▇▃▃▄▅ ▃▂▁▆ + █ ▁▁▅▄▄▂ ▂▃▆▁▃▃▁▇▆ + ▅▇█▄▃▇█▁▇▆ ▇▅█▇▅▇▁▁ + ▁ ▁▁▁█▃▁▃▄▂▂ ▁█▁▇▁▁▁ + ▂ ▁▇▃▁▇▇▁▃▃▆█▅▆▅ ▂▁▁ + ▁ ▁▅▂▆▃▁▁▁▂▅▄▃▂▁ ▁▁ + █▆▁█ ▅▁█▁▁▁▁▇▁▁▆▁▁▂ + ▁▂▁▁▁▂▆▁▃▁▂▁▁▁▁▂▂▁▁▁ + █ ▂▃▅▃▃▁▅█▇▇▇▇▁▁█▅▃▁ + ▃▁▁▁▃▆▂ ▆▅ ▃▅▁▂ + ▃▇▃▁▃▃ ▅▄▂▄▁▅▇ + ▃▆█▃▂▁▇▅▇▄▄▄▇▁▂ + █▆▂ ▇▃▃▁▄▇▅█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_1.txt b/codex-rs/tui2/frames/openai/frame_1.txt new file mode 100644 index 00000000000..1019a11c958 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_1.txt @@ -0,0 +1,17 @@ + + aeaenppnnppa + anpeonpepnniina aopa + pioipoooaa aooaoiniiip + noanooppa eoinip + naneoainann oeinnp + io pa ioeniip oeniip + paopo onioeoia iei + iiaia peeinio oai + inioa niianioeippppppapp no i + aino anpa eo ioiaaaeepppiepepi + naaoa anpeea aaoooo aooepnae + oap op a poeoai + anpanpa anapiapo + aopna opennnnenopapio + aoooiiiaaapanpoo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_10.txt b/codex-rs/tui2/frames/openai/frame_10.txt new file mode 100644 index 00000000000..942f59e944f --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_10.txt @@ -0,0 +1,17 @@ + + apooonppa + ooapopnieaop + aapiieiipipnpp + iaaeninaenpoonnp + e niioia opeaeie + ia oioieen aaoi + n ioooiiiio iep + o iinpepiipaaenii + o in nniniipnpnii + i pinpiaeaoaaaioa + p oiiiiioo nponia + naopnpoo aioopee + io iiiiopepeiea + naooeipna eeo + op aonapno + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_11.txt b/codex-rs/tui2/frames/openai/frame_11.txt new file mode 100644 index 00000000000..ef0aff76e0f --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_11.txt @@ -0,0 +1,17 @@ + + pooooppa + eo niiinnp + eoaaiinnoenp + iaaiinnpanee + aoennipnonaiip + ipaiipanniaeii + oo piiiiiiainn + i iieiine ioi + e oieiioepiai + oo aienpoeiaia + oap iiiineinni + napeiaoeoonia + a oinnaaino + np oiipaeoa + na oopapa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_12.txt b/codex-rs/tui2/frames/openai/frame_12.txt new file mode 100644 index 00000000000..8940e05bd67 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_12.txt @@ -0,0 +1,17 @@ + + pooope + pnaaeipn + o ienpp + epiiinnoi + i oiiiieei + ioeeoaoaio + i ieniin + i epniio + op ieiipii + i eionoa + ie ionooe + p oi api + e ooiponi + oaeopinia + n oinee + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_13.txt b/codex-rs/tui2/frames/openai/frame_13.txt new file mode 100644 index 00000000000..c73afab740d --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_13.txt @@ -0,0 +1,17 @@ + + eooop + iaaii + iaaio + iooia + o ii + oepii + p ii + p oi + ep aie + io pni + oaee ia + iaaai + ippia + oaain + p eon + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_14.txt b/codex-rs/tui2/frames/openai/frame_14.txt new file mode 100644 index 00000000000..8a273a1666a --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_14.txt @@ -0,0 +1,17 @@ + + pooon + peaaen + iaiie i + iieioaip + iiin i + ioii oi + iiii e o + iinia n + iiii o + oaee ne + nioopepi + ioiiaaai + e oinnna + ineoaae + ao one + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_15.txt b/codex-rs/tui2/frames/openai/frame_15.txt new file mode 100644 index 00000000000..5a0e8f1b549 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_15.txt @@ -0,0 +1,17 @@ + + ppoooia + apniiaeop + eeionniooi + iea eepeaai + iinonniieoai + niipiieei o + ii iiiiiipi + iieiipiii opi + iipppoiiiaeei + iaineinioe ei + ieiiiiai a n + iooeaiinp n + oinp eeea na + iaiinnepne + naoaaaae + aa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_16.txt b/codex-rs/tui2/frames/openai/frame_16.txt new file mode 100644 index 00000000000..06c519f6028 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_16.txt @@ -0,0 +1,17 @@ + + anpooopia + eaiioiininn + ieeonennii on + eee noaniniiooi + iinaeooniioeian + iiioio inn i iei + nni eapiiiieoop i + ien iaiieiiion ai + nniiipieaiioop e + ooaapnnnoieai pai + iiipooeoninoneiia + iio ia aeep e + nio i epeapi + niioaoieepai + oeeaneaano + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_17.txt b/codex-rs/tui2/frames/openai/frame_17.txt new file mode 100644 index 00000000000..0bd4ef6dfc5 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_17.txt @@ -0,0 +1,17 @@ + + pnpppnnipa + anooiiionoipoap + poneannappoiiiaon + eieaeioaa oinniiinn + iea i ioennnee p + anopea peei epneiee + o nei peeia ooiieni + poni eeiieipiinno + peoennnpinainaeniii i + ioanaaieoneiin npopn + nnneooooooonaioeen no + niiao aeiia n + ann ona popeaae + p iopnnpaanoeio + apneappioapo + a \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_18.txt b/codex-rs/tui2/frames/openai/frame_18.txt new file mode 100644 index 00000000000..de59f344efe --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_18.txt @@ -0,0 +1,17 @@ + + annpnnpnipa + apoanpppiaapopnna + aeeiooonopoeaopeen o + piioepaoo onaaeninnnnnp + anee ei aopaneieiannnna + eni ppieaaaepopiopeaeaei + iinee peoeioaeooininp + onpii anoiina inio + noo pnnnnnnpiipioenp ioia + anniono iieaanaiiianian p + ieinaoooooooa naaeieoeapa + nn pnp aaooeoao + naopaae aoaaenpa + oieooppnnnaooaeapo + oiiaeaapeponpo + a \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_19.txt b/codex-rs/tui2/frames/openai/frame_19.txt new file mode 100644 index 00000000000..ade56623593 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_19.txt @@ -0,0 +1,17 @@ + + aannpipnniea + apiiinennnaoapiopp + ponneoipoa aoepaonn + eniopaa annppaiop + iieaea piaoeoin inp + ineoo aioie ee oeoi + aniio eoaeippa eeepi + iiii inoniop i iii + ioinpnnnnnniiiiponoin n oii + anepinoeaopnpap aa enanpeeia + onoepioooooooo anaeiee eeo + nnaoan aeeaeeo + o aniiop pnneeina + ooeioeipaanneoo noio + oieaepoapeaopa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_2.txt b/codex-rs/tui2/frames/openai/frame_2.txt new file mode 100644 index 00000000000..be49360bbf5 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_2.txt @@ -0,0 +1,17 @@ + + aeaenpnnnppa + appeonpiiiniinaoiopa + pioinooioaa aoonoeiinip + aieaeooppa ppnieip + paoeoainanna apinip + oepoa ionpenp nnioi + piooi aeiniaip eoioi + io na pioiino ioeei + oooni niaaaioeipppppppppiaiio + anoi eiio eo innaaaaeeaaipe i + nnn o oneeio aoooooooooipoii + onaeep a ppnepo + enaaipp pnap aio + aianaaoopennnnenoaaiio + aopopiiaaaaineoo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_20.txt b/codex-rs/tui2/frames/openai/frame_20.txt new file mode 100644 index 00000000000..6eaf358e88d --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_20.txt @@ -0,0 +1,17 @@ + + aapniinpnpaa + pnnonpapnnniiaaonpp + ppoapooioo aoaonpoipa + aioaoio appineonn + ai pio ae aiiaoniin + eapio poonioe oniin + ninpi po epoa pnie + iaaea ooneiop iioi + ieiinpnnnnnnnnaponaanno e in + oiaiao eaaaaaain onnoinn pe ii + npanoooooooooo npniipi en + o aoep aeoeie + naaopia pnoanoo + appeoipannpeoioapeoo + aopia naaaaiooo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_21.txt b/codex-rs/tui2/frames/openai/frame_21.txt new file mode 100644 index 00000000000..5f317f375c5 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_21.txt @@ -0,0 +1,17 @@ + + aapnpnnpnppaa + ppoaaaapinipnaapopa + noaapoaoa aaiopaaop + poinoo apnopnop + epeoa piean nnaap + eoei pooaee nnap + ioia a eapeo nnno + an nn oip i i + i i ppnnnnnppp aipoip ne i + i in ia api aoaoip eoee + n oe aaooo oa ooapn ea p + op ena aoo e + oa apa aenoo pa + ona ooopeiinenooo pa + oppnpaaaapanpoa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_22.txt b/codex-rs/tui2/frames/openai/frame_22.txt new file mode 100644 index 00000000000..74b75b91135 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_22.txt @@ -0,0 +1,17 @@ + + appnpnnpnppa + apeoieaeieniinipeopa + pnoninooa aoonoopaapp + noiooo ppiiiaon + niieo paeinieenoi + nioea pieinaea ninni + ipone ipinoeo iiei + iinia iniian iaioi + iipop pinnnnnnppanoia np aino + ain oannp epoieei nonaoipp enn + nnnai ooooooooa aeopaeeeopa + nonanp aopoipo + oopn ppa aeopooae + oeoppooinnpnnenoooopo + oioianaaapnaenoa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_23.txt b/codex-rs/tui2/frames/openai/frame_23.txt new file mode 100644 index 00000000000..35e7fe2210d --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_23.txt @@ -0,0 +1,17 @@ + + appanpoppnpaa + anipinapannpiaioipa + popiopnoa oiepinniip + enionea annniinn + eeiaeo peppooeonin + pnioea aoooooeooiinip + aiioi eeee aia o ioo + iei i ioipanp i oei + aon ipinnnnnnnniaoiaona i iii + innaiono ean nianpi nai + eoi i aooooooo oaenniaeaea + piiaie peinaea + n ipoeaa aeonoepe + nnnpnopninnappopapoa + oi onniaapaaooa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_24.txt b/codex-rs/tui2/frames/openai/frame_24.txt new file mode 100644 index 00000000000..a74ea1f0bb7 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_24.txt @@ -0,0 +1,17 @@ + + aianpponnpa + poaeoiaainnppaop + poonnepoi eaeoioop n + poeepeinpnaiea aoninao + p eenep aooenaiaanipo + ipnpna oppe ein eon + i ii peapeoeaninaia + i nii ipnenaiiaeeaii + ie innneppiiippaopai i ii + a e oieppnnppieanpoi ioi + n oninoooooooa npeoninepa + n npon i oene + neonaie anoaeeao + epopnaoiiennniaaea + oeaopnnaannpo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_25.txt b/codex-rs/tui2/frames/openai/frame_25.txt new file mode 100644 index 00000000000..c2c5b30b296 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_25.txt @@ -0,0 +1,17 @@ + + apnonppppa + p opoaannioiip + eieeppoeipnnniiia + ipoi e ni onipone + paanpeoaneaio ieeneo + npiiiaopeaie eipieeip + ieioi iaaenaeineaoini + iiieieo ianneinainiin + apnioiaannnnpiinnipoin + epoieipiaiinoainoneni + nioeniaoooooopeinieia + iaoinpnnppoeaepeeie + aaaianaonepaeaiie + nennnaonioo oio + ipoonppapio + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_26.txt b/codex-rs/tui2/frames/openai/frame_26.txt new file mode 100644 index 00000000000..09a947d35d6 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_26.txt @@ -0,0 +1,17 @@ + + anoooopp + eoinenipnip + eoooinninoonp + noopiiiaiiapea + iapnieiooneninn + n oieeeooanonne + i ooniieiinnop + ep niiiaaniieii + i enonpipnnenii + eaiepiaaponnoi + iaoeioonpeapnii + naiien eeoiia + o oin aaeiep + ioonnooaoon + neeaoiano + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_27.txt b/codex-rs/tui2/frames/openai/frame_27.txt new file mode 100644 index 00000000000..b3fef11ac8c --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_27.txt @@ -0,0 +1,17 @@ + + nooopp + ineeien + noa oieoi + iiiaiinii + i aiapon + iaeaoaiiia + n oiiaoii + i aiiinoii + ie e ipiiii + on eoaiaoii + ip aaoopeo + iippiiaei + innpnpeia + naainnee + ono ane + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_28.txt b/codex-rs/tui2/frames/openai/frame_28.txt new file mode 100644 index 00000000000..11fdcec5207 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_28.txt @@ -0,0 +1,17 @@ + + eoon + ipaia + n nia + ipaoi + i ii + i poi + aii ai + ipooi + en ai + iieeii + i ai + inpni + inoii + oaaei + i epi + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_29.txt b/codex-rs/tui2/frames/openai/frame_29.txt new file mode 100644 index 00000000000..2dc6c667532 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_29.txt @@ -0,0 +1,17 @@ + + poooop + pioe on + apii ooi + iiiianpe + n ioi i + ipiii aa + inniii a + iinii + iiioi a + ainii a + oiieia n + ieiii ai + iiiiaiai + inioeoio + iio ep + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_3.txt b/codex-rs/tui2/frames/openai/frame_3.txt new file mode 100644 index 00000000000..9026d59a430 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_3.txt @@ -0,0 +1,17 @@ + + aennppnnnnpa + appeniipionninaaiopa + pneonioooa aoi oopeaip + epeninppa ano iin + oeoieaeiiona naniia + einne epinnnn i nni + no i ooniaoip eoeip + ppipi ppioeni iaini + pooi niopiiopinppaaappaiaoni + pnenp eeoenni ioiaap nepnnainia + oai i oiippo ooooooooopeeepe + ooi np a eapaee + naeeonp ppneopio + nappaooannnnpenonaapo + aopoaeiaaaaineao + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_30.txt b/codex-rs/tui2/frames/openai/frame_30.txt new file mode 100644 index 00000000000..73b4906d0ec --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_30.txt @@ -0,0 +1,17 @@ + + anooopp + aeniinoonn + ooaipnnippn + iinieianin np + noiianipip i + ioiap oiiiiei + neiiina neni na + oi opnipioip a + oiaoninineip a + oiiniaaii pp a + niinnaoiiipii + ioeepeoniio i + niniieoiipie + pappnaeipeo + npeiaaano + aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_31.txt b/codex-rs/tui2/frames/openai/frame_31.txt new file mode 100644 index 00000000000..cc71fce9200 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_31.txt @@ -0,0 +1,17 @@ + + aeeopopnp + aponppaopnoop + pineepnaaeoiaon + piepaaonapnniniin + aiiiipponaponieaa + niaaiaioonnopeeipoi + eii iaoinenenoioiei + nnp iioin iaooiinei + inpaioaiiiepiiia i + oiiieeiiaaanainii o + aoaepip eoooaeioia + iiinnnp ano i + niona p e ioaea + einieaiapiopia + ooniapoeeno + aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_32.txt b/codex-rs/tui2/frames/openai/frame_32.txt new file mode 100644 index 00000000000..c0d6573da78 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_32.txt @@ -0,0 +1,17 @@ + + pppppnnipa + noiooiniiooiip + eaonappaepaoionoop + oeeiapanp p panipp p + eeipionooin oapinenonp + nennpnoiaeiipa ipe o + oionpaoniippipnaeaeeioa + iiipa oaiioeoinpaoniooa + niipa oaioaaipppiiiniona + oooaaiononaaaaaiioinipa + pnoiie iooooooaneeeee + npiann pnieoi + oooiiipa aeaie p + npnoipinnoaapoae + innaappeoaoo + aaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_33.txt b/codex-rs/tui2/frames/openai/frame_33.txt new file mode 100644 index 00000000000..56ef96d36a8 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_33.txt @@ -0,0 +1,17 @@ + + appnpnpniea + apoiinonieipioioe + aenipoppnoa ooeiipop + pneieaipa oiinao + aeieaiienan ipnna + eep nannanp enean + iip npoiain ioii + oipp poonenp noio + iii i pa eopppnnnpiipeaini + aeiooeepeaiiaaaaaiioaaaiie + nnnp ieie e ooooooo iipeaa + npnopop eenene + onooponp apipeono + nioonpipnpaonpnoio + onnppaaaaponpo + aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_34.txt b/codex-rs/tui2/frames/openai/frame_34.txt new file mode 100644 index 00000000000..b6e87c62f1c --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_34.txt @@ -0,0 +1,17 @@ + + apnppppnipa + apopennioiponoonpna + aeepiooeioa aannpooop + peaioaepa oiponn + eeinpei io p nnona + peniae nnoinon npnin + ioaia onaoiaa inia + ninoe ioaieoi e an + poeeo eneeoannppppppiaao iaa + iinpeppaoeeoiiiaieaaapeaiiain + eanp inioa oooaaaaa pepnio + epnnnna eneieo + npaipnnp apnoaepa + pneioippnppnonoapoeo + oinnnaaaaannona + aaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_35.txt b/codex-rs/tui2/frames/openai/frame_35.txt new file mode 100644 index 00000000000..899d6766b79 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_35.txt @@ -0,0 +1,17 @@ + + appnnpppnppa + ponoeioopoioieoiona + aeeinpoiaooa aoonoeoiop + peepepnpa opania + eeiieen onop inoip + nenepa oiaoeeoa inni + n ee nn ooae iann + iiie ennneo e oe + e pa piienoeinnananpii iino + ipano eenoopein npaaaaeoieo io + eanpoenaieaa aoaaaaoaaoeeapi + oeanei aeo pa + nnaanpep pneaooo + oaaoioipeiipineooaeno + oainnaaeaappooa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_36.txt b/codex-rs/tui2/frames/openai/frame_36.txt new file mode 100644 index 00000000000..9a23d2ddd6d --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_36.txt @@ -0,0 +1,17 @@ + + aapnppppnppa + anoonpenepnpnppooopa + pnonppopooaa aooiopipoip + aioopaaaa ooinoi + peannoinanpe aneo + e pea oa oiep onao + i n a n onn iaip + iini an aee ni i + iooi ee pooopppapppppa inii + aoin piaaeie aiaaaaaaaii neoa + o oin papea oooaaaaao ipoe + oponne peioe + neiipip pppoepa + opaooonaniapinopopnpo + aopeiappappppooo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_4.txt b/codex-rs/tui2/frames/openai/frame_4.txt new file mode 100644 index 00000000000..0c76cc5ce83 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_4.txt @@ -0,0 +1,17 @@ + + aennpnnpnppa + apopoie inneonioeopa + poniiipooa aainaoienna + eeipioepp oioonon + eieaininnaip nonion + eiiai oaiieon o nnip + onina ooonpoip p oni + ien pieoaii iii + ai ia nieoaeepipppaaaapp iio + ine i peeepaiannipeapeeanieeoi + ainonpnnpne oooooooooapiiia + pinoiaa paneooo + nnpea pa aeaponie + iiinaoonnnnnppnopnioa + onnieiaaaaanpoa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_5.txt b/codex-rs/tui2/frames/openai/frame_5.txt new file mode 100644 index 00000000000..2b06cade095 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_5.txt @@ -0,0 +1,17 @@ + + aennnpnnnppa + apopeoeiipinpnaiopp + oaenpiooa oanion pop + pieieienp paiiiia + pneoeaapianp a ninia + iiiie nainneia peii + iei p anainoip ooipip + ieiao panieai piai + ipiao eaiopionaipiaaappaoiei + aninppepoo io eennapnepeieoia + npiipipnnepa oooooooopinine + nnpenn a eaeopa + aeoninpa anppoono + aioipopnninappnoaino + apoaooaaapappoa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_6.txt b/codex-rs/tui2/frames/openai/frame_6.txt new file mode 100644 index 00000000000..2ca8bb0bc79 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_6.txt @@ -0,0 +1,17 @@ + + aennnpnnppaa + paneoinopippnapopa + pppiiioo aoienopoena + epioioanp npiiiip + oniiionnpon enninp + npeieaapninaip ennei + a pii aniean apiai + i iii ainieii ipnii + aeipi eoiionepaipiaappaenei + iinpeaaanp ienopiipnnnnipin + e i eiiineo ooooooooannei + p n epa peaeeia + eanaoop apaaeopp + aoiopop niaappoo eno + apnoppaaaaappo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_7.txt b/codex-rs/tui2/frames/openai/frame_7.txt new file mode 100644 index 00000000000..f66ddaf5a65 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_7.txt @@ -0,0 +1,17 @@ + + aeinopnnppa + poapnipopnnnn aop + o piiioa aaia oeaop + nniiiinp ainoiep + aoioiiaienp e oiin + i niio aonnin ppiii + anoi aainini ooaii + aoeii npeioia o iii + eoio poaeineiinnaiinnonii + ipoiineiieoieniinnpnpnnini + n naeoioae ooooooooaeii + oa eaeaa popeneo + onppnnna paipoeoo + naonionniaaapoiope + oiooieiaapanoo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_8.txt b/codex-rs/tui2/frames/openai/frame_8.txt new file mode 100644 index 00000000000..e54163d2c8a --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_8.txt @@ -0,0 +1,17 @@ + + aapanppnppa + pooeiiionn i op + ainpieeo poieionpap + aonoeippi o naiin + o eiepnien eoniip + iopinaaaniao npiii + ioiii oanniai iaieip + npiie ipneipoe eaeii + n niiaa eoniiepppppniii + noenaiopipiininaaiino + eenniniiieoaoaoonooeni + opoiniaa epeeeo + onaonnna aenaipeo + eeoipo nnponenpia + iiaooniappena + aa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_9.txt b/codex-rs/tui2/frames/openai/frame_9.txt new file mode 100644 index 00000000000..a339de11184 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_9.txt @@ -0,0 +1,17 @@ + + enaoeppnp + eoaeiioon apna + e piieoaane naip + o iienna aapinaipp + eoonnooiop oeopepii + i iiioainnaa ioioiii + a ioniooinnpoepe aii + i ieapniiiaennai ii + opio eioiiiipiipiia + iaiiiapiaiaiiiiaaiii + o aaennieoooooiioeni + niiinpa pe aeia + npninn enanieo + aponaioepnnnoia + opa onninpeo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_1.txt b/codex-rs/tui2/frames/shapes/frame_1.txt new file mode 100644 index 00000000000..244e2470b4f --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_1.txt @@ -0,0 +1,17 @@ + + ◆△◆△●□□●●□▲◆ + ◆●▲△□○□△□○●◇◇●◆ ◆■□◆ + ▲◇□◇□□□■○◆ ◆■□◆■◇●◇◇◇□ + ●□◆○□■▲▲◆ △□◇●◇▲ + ○○●△■○◇○◆○○ ■△◇○○▲ + ◇□ □◆ ◇□△●◇◇▲ ■△○◇◇▲ + □○■▲□ ■○◇□△■◇◆ ◇△◇ + ◇◇◆◇◆ ▲△△◇●◇□ ■◆◇ + ◇●◇■◆ ●◇◇○○◇■△◇□□□□□□◆□▲ ●■ ◇ + ◆◇●□ ◆●□◆ △□ ◇■◇◆◆◆△△▲▲▲◇△▲△▲◇ + ○○◆■○ ○○▲△△◆ ◆○□■■□ ○□■△▲●◆△ + □○▲ ■▲ ◆ ▲■△□◆◇ + ○○▲◆○□◆ ◆●◆□◇◆□■ + ○□▲○◆ □□△●●●●△●□□◆▲◇□ + ◆□■□◇◇◇◆◆◆▲◆●□□■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_10.txt b/codex-rs/tui2/frames/shapes/frame_10.txt new file mode 100644 index 00000000000..f306dffc087 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_10.txt @@ -0,0 +1,17 @@ + + ◆□□□□○□□◆ + □■◆□□□○◇△◆□▲ + ○◆▲◇◇△◇◇▲◇□○▲▲ + ◇◆◆△○◇●◆△○▲■■○○▲ + △ ●◇◇■◇○ ■▲△◆△◇△ + ◇◆ ■◇□◇△△○ ◆◆■◇ + ○ ◇□■□◇◇◇◇□ ◇△▲ + ■ ◇◇○□△□◇◇▲◆◆△○◇◇ + ■ ◇○ ○○◇●◇◇□○□●◇◇ + ◇ ▲◇○▲◇◆△◆□◆◆◆◇□◆ + ▲ ■◇◇◇◇◇■■ ○▲■○◇◆ + ○◆■▲○▲□■ ◆◇■■▲△△ + ◇■ ◇◇◇◇□▲△▲△◇△◆ + ●◆□□△◇□●◆ △△■ + □▲ ◆□○◆▲●□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_11.txt b/codex-rs/tui2/frames/shapes/frame_11.txt new file mode 100644 index 00000000000..dcf944902b3 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_11.txt @@ -0,0 +1,17 @@ + + ▲□□□□□□◆ + △■ ●◇◇◇○○▲ + △■◆◆◇◇●○□△○▲ + ◇◆◆◇◇●●▲◆●△△ + ◆■△●○◇□○■●◆◇◇▲ + ◇□◆◇◇□◆●●◇◆△◇◇ + □□ ▲◇◇◇◇◇◇◆◇●○ + ◇ ◇◇△◇◇○△ ◇■◇ + △ ■◇△◇◇□△□◇◆◇ + □□ ◆◇△●▲■△◇◆◇○ + ■◆▲ ◇◇◇◇●△◇○○◇ + ○◆▲△◇◆□△□□●◇◆ + ◆ □◇○○○◆◇●■ + ○□ □◇◇▲◆△□◆ + ○◆ ■□□◆□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_12.txt b/codex-rs/tui2/frames/shapes/frame_12.txt new file mode 100644 index 00000000000..d8d1fbf334f --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_12.txt @@ -0,0 +1,17 @@ + + □□□□□△ + ▲●◆◆△◇▲○ + ■ ◇△○□▲ + △□◇◇◇●●■◇ + ◇ ■◇◇◇◇△△◇ + ◇■△△□○■◆◇■ + ◇ ◇△○◇◇○ + ◇ △□○◇◇■ + □▲ ◇△◇◇□◇◇ + ◇ △◇□●□◆ + ◇△ ◇■●□□△ + ▲ □◇ ◆▲◇ + △ □□◇▲□○◇ + ■○△■▲◇○◇◆ + ○ ■◇○△△ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_13.txt b/codex-rs/tui2/frames/shapes/frame_13.txt new file mode 100644 index 00000000000..1387fc9b912 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_13.txt @@ -0,0 +1,17 @@ + + △□□□▲ + ◇◆◆◇◇ + ◇◆◆◇■ + ◇□□◇◆ + □ ◇◇ + ■△▲◇◇ + ▲ ◇◇ + □ ■◇ + △□ ◆◇△ + ◇■ □●◇ + ■◆△△ ◇◆ + ◇◆◆◆◇ + ◇□▲◇◆ + □◆◆◇● + ▲ △■● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_14.txt b/codex-rs/tui2/frames/shapes/frame_14.txt new file mode 100644 index 00000000000..70a5070ba9b --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_14.txt @@ -0,0 +1,17 @@ + + □□□□● + ▲△◆◆△○ + ◇◆◇◇△ ◇ + ◇◇△◇■○◇▲ + ◇◇◇○ ◇ + ◇□◇◇ ■◇ + ◇◇◇◇ △ □ + ◇◇○◇◆ ○ + ◇◇◇◇ ■ + ■○△△ ●△ + ○◇■■▲△▲◇ + ◇□◇◇◆◆◆◇ + △ ■◇●●●◆ + ◇○△□◆◆△ + ◆□ ■●△ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_15.txt b/codex-rs/tui2/frames/shapes/frame_15.txt new file mode 100644 index 00000000000..584e0e043a9 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_15.txt @@ -0,0 +1,17 @@ + + □□□□□◇◆ + ◆▲●◇◇○△□▲ + △△◇□●○◇■□◇ + ◇△◆ △△▲△◆◆◇ + ◇◇●■●○◇◇△■○◇ + ○◇◇▲◇◇△△◇ ■ + ◇◇ ◇◇◇◇◇◇▲◇ + ◇◇△◇◇□◇◇◇ ■▲◇ + ◇◇□▲▲□◇◇◇◆△△◇ + ◇◆◇●△◇○◇□△ △◇ + ◇△◇◇◇◇◆◇ ◆ ● + ◇□□△○◇◇○▲ ● + ■◇○▲ △△△◆ ●◆ + ◇○◇◇○○△□○△ + ○○□○◆◆◆△ + ◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_16.txt b/codex-rs/tui2/frames/shapes/frame_16.txt new file mode 100644 index 00000000000..af6c8368553 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_16.txt @@ -0,0 +1,17 @@ + + ◆●□■□□□◇◆ + △○◇◇□◇◇○◇●○ + ◇△△■○△●○◇◇ ■○ + △△△ ●□◆○◇○◇◇□□◇ + ◇◇●◆△□■●◇◇□△◇◆○ + ◇◇◇□◇■ ◇○● ◇ ◇△◇ + ○○◇ △◆▲◇◇◇◇△□■▲ ◇ + ◇△● ◇◆◇◇△◇◇◇■● ◆◇ + ○○◇◇◇□◇△○◇◇■□□ △ + □■◆◆▲●●○□◇△◆◇ ▲◆◇ + ◇◇◇□■■△□○◇●■●△◇◇◆ + ◇◇□ ◇◆ ◆△△▲ △ + ○◇□ ◇ △▲△◆▲◇ + ○◇◇■◆□◇△△□◆◇ + ■△△◆●△◆◆●□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_17.txt b/codex-rs/tui2/frames/shapes/frame_17.txt new file mode 100644 index 00000000000..4a158cf6094 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_17.txt @@ -0,0 +1,17 @@ + + ▲●□□□●●◇▲◆ + ◆●□□◇◇◇□●□◇▲■○▲ + ▲□○△◆●●◆□▲■◇◇◇◆■○ + △◇△◆△◇■◆◆ ■◇●○◇◇◇●○ + ◇△◆ ◇ ◇■△○●○△△ ▲ + ◆●□▲△◆ ▲△△◇ △▲●△◇△△ + □ ●△◇ ▲△△◇◆ □■◇◇△●◇ + ▲■●◇ △△◇◇△◇▲◇◇●●□ + ▲△□△○●●□◇○◆◇○○△○◇◇◇ ◇ + ◇■◆●◆◆◇△□○△◇◇○ ○□■□○ + ○○○△■■■□□□□○◆◇□△△○ ○■ + ○◇◇◆□ ◆△◇◇◆ ● + ◆○○ □○◆ ▲■▲△○◆△ + ▲ ◇□□●○□◆◆●□△◇■ + ◆□●△◆▲□◇□◆□□ + ◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_18.txt b/codex-rs/tui2/frames/shapes/frame_18.txt new file mode 100644 index 00000000000..16bf8c1b581 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_18.txt @@ -0,0 +1,17 @@ + + ◆●●□●●□●◇▲◆ + ◆□■◆●▲□□◇◆◆▲■□●●◆ + ◆△△◇□■□●■▲■△◆■□△△○ □ + ▲◇◇□△▲○■□ ■○◆◆△●◇○●○○○▲ + ◆○△△ △◇ ◆■□◆○△◇△◇◆○●○○◆ + △○◇ ▲▲◇△◆◆◆△▲□▲◇■▲△○△◆△◇ + ◇◇○△△ □△□△◇■◆△■□◇●◇●▲ + ■○▲◇◇ ○○■◇◇●○ ◇●◇■ + ○■■ ▲○●●●●●□◇◇▲◇□△○▲ ◇□◇◆ + ◆○○◇□○□ ◇◇△◆◆○◆◇◇◇◆●◇◆○ ▲ + ◇△◇○◆■■□□□□□◆ ○◆◆△◇△□△◆▲◆ + ○○ ▲○▲ ◆◆□■△□◆■ + ○○□□○○△ ◆□○◆△○▲◆ + ■◇△□□□□●●●○■□◆△◆▲□ + ■◇◇◆△◆◆▲△□■●□■ + ◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_19.txt b/codex-rs/tui2/frames/shapes/frame_19.txt new file mode 100644 index 00000000000..e1bc51ae1be --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_19.txt @@ -0,0 +1,17 @@ + + ◆◆●●□◇□●●◇△◆ + ◆□◇◇◇○△●●●◆□◆▲◇□▲▲ + ▲□●●△■◇□□◆ ◆□△▲◆□●○ + △●◇□▲○◆ ◆●●□▲○◇□▲ + ◇◇△◆△◆ ▲◇○■△■◇○ ◇○▲ + ◇○△□■ ◆◇□◇△ △△ ■△□◇ + ○●◇◇■ △□○△◇□▲◆ △△△□◇ + ◇◇◇◇ ◇●■○◇■▲ ◇ ◇◇◇ + ◇□◇○▲●●●●●●◇◇◇◇□□●□◇● ○ ■◇◇ + ○●△▲◇●□△○□□○▲◆▲ ○◆ △○◆○▲△△◇◆ + ■○■△□◇■■■■■□□■ ○○◆△◇△△ △△■ + ○○○□◆○ ◆△△◆△△■ + ■ ◆○◇◇□▲ ▲●●△△◇●◆ + ■■△◇□△◇□◆◆●●△□■ ●□◇■ + ■◇△◆△▲■◆▲△○□▲○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_2.txt b/codex-rs/tui2/frames/shapes/frame_2.txt new file mode 100644 index 00000000000..af71459f5e9 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_2.txt @@ -0,0 +1,17 @@ + + ◆△◆△●□●●●□▲◆ + ◆□▲△□●□◇◇◇●◇◇●◆■◇□□◆ + ▲◇□◇○□□◇□◆◆ ◆■□●□△◇◇●◇▲ + ◆◇△◆△□■▲▲◆ ▲▲○◇△◇▲ + ▲○■△□○◇○◆○○◆ ○▲◇○◇▲ + ■△□■◆ ◇□○□△○□ ○○◇□◇ + ▲◇■□◇ ○△◇●◇◆◇▲ △■◇■◇ + ◇■ ○◆ ▲◇□◇◇●□ ◇■△△◇ + □■■●◇ ●◇○○◆◇■△◇□□□□□□□□▲◇◆◇◇□ + ◆●■◇ △◇◇■ △□ ◇●○◆◆◆◆△△◆◆◇□△ ◇ + ○○○ □ □○△△◇■ ◆■□□□□□□□■◇▲□◇◇ + ■○◆△△▲ ◆ ▲□●△▲□ + △○◆◆◇□▲ ▲●◆□ ◆◇■ + ◆◇○●◆◆■□□△●●●●△●□◆◆◇◇□ + ◆□□□□◇◇◆◆◆◆◇●△□■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_20.txt b/codex-rs/tui2/frames/shapes/frame_20.txt new file mode 100644 index 00000000000..c5eb01382d6 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_20.txt @@ -0,0 +1,17 @@ + + ◆◆□●◇◇●□●▲◆◆ + ▲●●■●▲◆▲●●●◇◇◆○□○□▲ + ▲▲■◆□□■◇□■ ◆■○■○□□◇□◆ + ◆◇■◆□◇□ ◆▲□◇○△□○○ + ◆◇ ▲◇□ ◆△ ◆◇◇○□○◇◇○ + △◆▲◇■ □□■●◇■△ ■○◇◇○ + ●◇●□◇ ▲□ △□□○ ▲○◇△ + ◇◆○△◆ ■■●△◇■▲ ◇◇■◇ + ◇△◇◇●▲●●●●●●●●◆▲■●○○○○□ △ ◇○ + ■◇○◇◆■ △◆◆◆◆◆◆◇○ □○○■◇●○ ▲△ ◇◇ + ○▲○○■■■■■■■■□■ ○▲●◇◇▲◇ △○ + □ ○□△▲ ◆△□△◇△ + ○◆○□▲◇◆ ▲●□◆●□■ + ○▲▲△■◇□○●●▲△■◇■◆▲△□■ + ◆□□◇◆ ●◆◆◆◆◇□■■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_21.txt b/codex-rs/tui2/frames/shapes/frame_21.txt new file mode 100644 index 00000000000..944b99f0581 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_21.txt @@ -0,0 +1,17 @@ + + ◆◆▲●□●●□●□▲◆◆ + ▲□□◆◆◆◆□◇●◇□●◆◆▲□□◆ + ●□○◆□□○■◆ ◆○◇□□◆◆□▲ + ▲□◇●■■ ◆▲●□▲●□▲ + △▲△□◆ ▲◇△◆○ ○○◆○▲ + △■△◇ ▲□□◆△△ ○○○▲ + ◇■◇◆ ◆ △◆▲△■ ○●○■ + ◆○ ○● ■◇▲ ◇ ◇ + ◇ ◇ ▲□●●●●●□□□ ○◇▲■◇▲ ●△ ◇ + ◇ ◇○ ◇◆ ◆▲◇ ○■◆□◇▲ △■△△ + ● ■△ ◆◆■■■ ■◆ ■□◆▲● △◆ ▲ + ■□ △●◆ ◆□■ △ + □◆ ○▲◆ ◆△●□■ ▲◆ + □●◆ □■□□△◇◇●△●□■■ ▲◆ + □□□●□◆◆◆◆▲◆●□□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_22.txt b/codex-rs/tui2/frames/shapes/frame_22.txt new file mode 100644 index 00000000000..60ea930d46d --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_22.txt @@ -0,0 +1,17 @@ + + ◆▲□●□●●□●□▲◆ + ◆□△□◇△◆△◇△○◇◇●◇□△□□◆ + ▲●□●◇○□□◆ ◆■□○□□□◆◆□▲ + ●□◇□□■ ▲▲◇◇◇◆□○ + ○◇◇△■ ▲◆△◇●◇△△●■◇ + ○◇□△◆ ▲◇△◇●○△◆ ○◇○●◇ + ◇□□○△ ◇□◇○□△■ ◇◇△◇ + ◇◇●◇◆ ◇○◇◇○○ ◇○◇■◇ + ◇◇▲■▲ □◇●●●●●●□□◆○□◇◆ ○▲ ◆◇●■ + ○◇● □◆○○▲ △▲■◇△△◇ ○□●◆■◇▲▲ △○● + ○○○◆◇ ■■■■■□□■◆ ◆△□□◆△△△■▲◆ + ○□○◆○▲ ◆□▲■◇▲■ + ■■□● □□◆ ◆△□▲■□◆△ + ■△□□▲■□◇●●□●●△●□■■■▲□ + ■◇■◇◆●◆◆◆▲●◆△●□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_23.txt b/codex-rs/tui2/frames/shapes/frame_23.txt new file mode 100644 index 00000000000..5d340640bf3 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_23.txt @@ -0,0 +1,17 @@ + + ◆▲□◆●□□□□●□◆◆ + ◆●◇□◇○◆▲◆●●▲◇◆◇□◇□◆ + ▲■□◇■▲●□◆ ■◇△□◇○●◇◇▲ + △●◇□●△◆ ◆●●○◇◇●○ + △△◇◆△■ ▲△▲▲■□△□○◇○ + ▲○◇■△◆ ◆□□□■■△□■◇◇○◇▲ + ◆◇◇■◇ △△△△ ◆◇◆ ■ ◇■□ + ◇△◇ ◇ ◇■◇▲◆○▲ ◇ □△◇ + ○■○ ◇□◇●●●●●●●●◇◆□◇◆■○◆ ◇ ◇◇◇ + ◇○○◆◇■●■ △◆○ ○◇◆○▲◇ ○◆◇ + △■◇ ◇ ◆■■■■■■■ ■◆△●●◇◆△○△◆ + ▲◇◇◆◇△ ▲△◇○○△◆ + ○ ◇▲■△◆◆ ◆△■●□△□△ + ○●○□●□□○◇●●◆□□■▲◆□□◆ + ■◇ □●○◇◆◆□◆◆□□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_24.txt b/codex-rs/tui2/frames/shapes/frame_24.txt new file mode 100644 index 00000000000..558224147dc --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_24.txt @@ -0,0 +1,17 @@ + + ◆◇◆●□□□●●▲◆ + ▲■◆△■◇◆◆◇●●□▲◆■□ + ▲■■●○△□■◇ △◆△□◇□□▲ ○ + ▲■△△▲△◇●▲○◆◇△◆ ◆□○◇○◆■ + ▲ △△○△▲ ◆■■△○◆◇◆◆○◇▲■ + ◇▲○□○◆ □▲▲△ △◇● △■○ + ◇ ◇◇ ▲△◆▲△■△○○◇○○◇○ + ◇ ○◇◇ ◇▲●△○◆◇◇◆△△◆◇◇ + ◇△ ◇●●●△□□◇◇◇□□◆□▲◆◇ ◇ ◇◇ + ◆ △ □◇△▲▲●●▲▲◇△○○□□◇ ◇□◇ + ○ ■○◇○□■■■■■■◆ ○▲△□○◇●△▲◆ + ○ ○▲□○ ◇ ■△●△ + ○△□○◆◇△ ◆●■◆△△◆□ + △▲□▲○○■◇◇△●●●◇○◆△◆ + □△◆□□●○◆◆●●□□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_25.txt b/codex-rs/tui2/frames/shapes/frame_25.txt new file mode 100644 index 00000000000..38d32507640 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_25.txt @@ -0,0 +1,17 @@ + + ◆□●□●□□□□◆ + ▲ □▲□◆◆●○◇□◇◇▲ + △◇△△▲▲■△◇□○●○◇◇◇◆ + ◇□□◇ △ ●◇ ■○◇▲□○△ + ▲◆◆○▲△■○●△◆◇■ ◇△△○△□ + ○▲◇◇◇◆□□△○◇△ △◇▲◇△△◇▲ + ◇△◇□◇ ◇◆◆△○◆△◇○△◆■◇●◇ + ◇◇◇△◇△■ ◇◆●○△◇○◆◇●◇◇○ + ○□○◇■◇◆◆●●●●□◇◇○○◇□□◇○ + △▲■◇△◇▲◇◆◇◇●□◆◇○□●△○◇ + ○◇□△○◇◆■■■■■■□△◇○◇△◇◆ + ◇◆■◇●□●○▲▲□△◆△▲△△◇△ + ○◆○◇◆○◆□●△▲○△◆◇◇△ + ○△●○○◆□●◇□□ □◇□ + ◇▲■□○□▲◆□◇□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_26.txt b/codex-rs/tui2/frames/shapes/frame_26.txt new file mode 100644 index 00000000000..4aac44389a9 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_26.txt @@ -0,0 +1,17 @@ + + ◆●□□□■□▲ + △□◇●△○◇□●◇▲ + △□■■◇●○◇○■■○▲ + ●□■▲◇◇◇◆◇◇◆□△○ + ◇◆▲○◇△◇■□○△●◇○● + ○ ■◇△△△■□◆○■○○△ + ◇ □■●◇◇△◇◇●●■□ + △▲ ○◇◇◇○○●◇◇△◇◇ + ◇ △○□●□◇□●●△○◇◇ + △◆◇△▲◇◆◆▲□○○□◇ + ◇◆■△◇□■○▲△◆□○◇◇ + ○◆◇◇△○ △△■◇◇◆ + □ □◇○ ○◆△◇△▲ + ◇■□○○■□◆□□● + ○△△○□◇◆○□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_27.txt b/codex-rs/tui2/frames/shapes/frame_27.txt new file mode 100644 index 00000000000..9896590f797 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_27.txt @@ -0,0 +1,17 @@ + + ●□□□□▲ + ◇●△△◇△○ + ●■◆ □◇△■◇ + ◇◇◇○◇◇●◇◇ + ◇ ◆◇◆▲■● + ◇◆△◆■◆◇◇◇◆ + ● ■◇◇◆□◇◇ + ◇ ◆◇◇◇●■◇◇ + ◇△ △ ◇□◇◇◇◇ + ■● △■◆◇◆□◇◇ + ◇▲ ◆○□□□△■ + ◇◇□□◇◇◆△◇ + ◇●●□●▲△◇○ + ○◆◆◇○●△△ + ■○■ ○○△ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_28.txt b/codex-rs/tui2/frames/shapes/frame_28.txt new file mode 100644 index 00000000000..16b349dc3d5 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_28.txt @@ -0,0 +1,17 @@ + + △□□● + ◇□◆◇◆ + ● ●◇◆ + ◇▲◆□◇ + ◇ ◇◇ + ◇ ▲■◇ + ○◇◇ ◆◇ + ◇▲■■◇ + △○ ◆◇ + ◇◇△△◇◇ + ◇ ◆◇ + ◇●□●◇ + ◇●□◇◇ + □◆◆△◇ + ◇ △□◇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_29.txt b/codex-rs/tui2/frames/shapes/frame_29.txt new file mode 100644 index 00000000000..24be1563b27 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_29.txt @@ -0,0 +1,17 @@ + + □□□□□▲ + ▲◇□△ □○ + ○▲◇◇ ■■◇ + ◇◇◇◇○○▲△ + ○ ◇■◇ ◇ + ◇▲◇◇◇ ◆◆ + ◇○○◇◇◇ ○ + ◇◇●◇◇ + ◇◇◇□◇ ◆ + ◆◇●◇◇ ◆ + □◇◇△◇○ ● + ◇△◇◇◇ ◆◇ + ◇◇◇◇◆◇◆◇ + ◇●◇□△■◇■ + ◇◇□ △▲ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_3.txt b/codex-rs/tui2/frames/shapes/frame_3.txt new file mode 100644 index 00000000000..3f55b79ac59 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_3.txt @@ -0,0 +1,17 @@ + + ◆△●●□□●●●●▲◆ + ◆□▲△○◇◇□◇■●●◇●◆○◇□□◆ + ▲●△□●◇□■■◆ ◆■◇ □□□△○◇▲ + △□△○◇●▲□◆ ◆●□ ◇◇○ + ■△□◇△○△◇◇□○◆ ○○○◇◇◆ + △◇○○△ △▲◇○●○○ ◇ ○○◇ + ●□ ◇ ■□○◇◆■◇▲ △□△◇▲ + ▲▲◇▲◇ ▲▲◇■△○◇ ◇◆◇●◇ + ▲■■◇ ●◇□□◇◇□▲◇●□□◆◆◆□□◆◇○■●◇ + ▲○△○▲ △△□△●●◇ ◇□◇◆◆▲ ●△▲●○◆◇○◇◆ + ■○◇ ◇ ■◇◇□▲■ ■□□□■■■□□▲△△△▲△ + ■□◇ ○□ ◆ △○□○△△ + ●○△△□○▲ ▲□●△■▲◇■ + ●○□▲◆□□○●●●●▲△●□●◆◆□■ + ◆□□□◆△◇◆◆◆◆◇●△○■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_30.txt b/codex-rs/tui2/frames/shapes/frame_30.txt new file mode 100644 index 00000000000..54886a319d0 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_30.txt @@ -0,0 +1,17 @@ + + ◆●■□□□▲ + ◆△●◇◇○□■●○ + ■■○◇▲○○◇□▲○ + ◇◇○◇△◇◆○◇○ ○▲ + ●□◇◇◆○◇▲◇▲ ◇ + ◇□◇○□ □◇◇◇◇△◇ + ○△◇◇◇○◆ ○△○◇ ●◆ + ■◇ ■□●◇▲◇■◇▲ ◆ + ■◇◆■○◇●◇○△◇▲ ◆ + ■◇◇○◇◆◆◇◇ □▲ ◆ + ○◇◇●○◆□◇◇◇□◇◇ + ◇□△△▲△□○◇◇■ ◇ + ○◇○◇◇△□◇◇▲◇△ + ▲○▲□●○△◇▲△■ + ○▲△◇○◆◆●■ + ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_31.txt b/codex-rs/tui2/frames/shapes/frame_31.txt new file mode 100644 index 00000000000..b3989b89df9 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_31.txt @@ -0,0 +1,17 @@ + + ◆△△□□□□●▲ + ◆□□●□▲◆■□○■□▲ + ▲◇○△△▲●◆◆△■◇◆□○ + ▲◇△□◆◆□●◆▲○○◇○◇◇○ + ○◇◇◇◇□▲□○◆▲■○◇△◆◆ + ●◇○◆◇◆◇■■○●□▲△△◇▲■◇ + △◇◇ ◇○□◇○△●△●□◇□◇△◇ + ○○▲ ◇◇■◇○ ◇○■■◇◇○△◇ + ◇○▲◆◇■○◇◇◇△□◇◇◇◆ ◇ + ■◇◇◇△△◇◇◆◆◆○◆◇○◇◇ □ + ○□◆△□◇▲ △□□□◆△◇■◇◆ + ◇◇◇○○○▲ ◆●□ ◇ + ●◇□●◆ ▲ △ ◇□◆△◆ + △◇○◇△◆◇◆▲◇■▲◇◆ + ■■●◇◆□□△△●□ + ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_32.txt b/codex-rs/tui2/frames/shapes/frame_32.txt new file mode 100644 index 00000000000..919eee3b0fd --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_32.txt @@ -0,0 +1,17 @@ + + ▲□□□□●●◇▲◆ + ●□◇□□◇○◇◇□□◇◇▲ + △○□●○▲□○△▲◆□◇□○■□▲ + ■△△◇◆▲◆●▲ ▲ ▲◆○◇□▲ ▲ + △△◇▲◇□○■□◇● ■◆▲◇○△○□○▲ + ○△○○▲○□◇◆△◇◇□◆ ◇□△ □ + □◇■●▲◆■○◇◇▲▲◇▲●◆△◆△△◇■◆ + ◇◇◇▲◆ ■◆◇◇■△■◇●▲◆■○◇■■◆ + ○◇◇▲◆ ■◆◇■◆◆◇□□□◇◇◇●◇■●◆ + ■■■◆◆◇□○■○◆◆◆◆◆◇◇□◇●◇▲◆ + ▲○□◇◇△ ◇□■■□□□○●△△△△△ + ○▲◇○○○ ▲○◇△■◇ + ■□□◇◇◇□◆ ◆△◆◇△ ▲ + ○▲○■◇□◇●●□◆◆□■◆△ + ◇●●◆◆▲□△□◆■■ + ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_33.txt b/codex-rs/tui2/frames/shapes/frame_33.txt new file mode 100644 index 00000000000..c5598aa7a73 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_33.txt @@ -0,0 +1,17 @@ + + ◆▲□●□●□●◇△◆ + ◆□□◇◇○□●◇△◇▲◇□◇□△ + ◆△○◇□□□▲●■◆ ■□△◇◇□■▲ + ▲○△◇△◆◇▲◆ □◇◇○◆□ + ◆△◇△◆◇◇△○○○ ◇□○○○ + △△▲ ○◆○○◆○▲ △○△◆○ + ◇◇□ ○▲□◇◆◇○ ◇□◇◇ + □◇▲▲ ▲■■○△●▲ ○■◇□ + ◇◇◇ ◇ ▲◆ △□▲▲▲●●●□◇◇□△◆◇○◇ + ○△◇■■△△▲△◆◇◇◆◆◆◆◆◇◇□○○○◇◇△ + ○●○▲ ◇△◇△ △ ■■■■□□□ ◇◇▲△○◆ + ○□○□▲■▲ △△●△●△ + □○■□▲□●▲ ◆□◇▲△□○■ + ○◇■□○□◇▲●□◆■●▲○□◇□ + ■●●□▲◆◆◆○□□●▲■ + ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_34.txt b/codex-rs/tui2/frames/shapes/frame_34.txt new file mode 100644 index 00000000000..5a44de82561 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_34.txt @@ -0,0 +1,17 @@ + + ◆▲●□□□□●◇▲◆ + ◆□□□△○●◇■◇□□●□□●▲●◆ + ◆△△▲◇□■△◇□◆ ◆○○○▲■□□▲ + ▲△◆◇□◆△▲◆ □◇▲□○○ + △△◇○▲△◇ ◇■ ▲ ○○□○○ + ▲△○◇◆△ ○○■◇○□○ ○▲○◇○ + ◇□○◇◆ ■○◆□◇◆○ ◇●◇◆ + ○◇●■△ ◇□◆◇△■◇ △ ◆○ + ▲■△△■ △○△△■◆●●□□□□□□◇◆◆■ ◇○◆ + ◇◇○▲△▲▲○■△△■◇◇◇◆◇△◆◆◆▲△◆◇◇◆◇○ + △◆○▲ ◇○◇□○ ■□□◆◆◆◆○ ▲△▲○◇■ + △▲○●●○◆ △●△◇△■ + ○□◆◇□○●▲ ◆□●■◆△▲◆ + ▲○△◇■◇□▲●▲□●□●□◆□□△■ + ■◇○●●◆◆◆◆◆●●□●○ + ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_35.txt b/codex-rs/tui2/frames/shapes/frame_35.txt new file mode 100644 index 00000000000..1c1728676b2 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_35.txt @@ -0,0 +1,17 @@ + + ◆▲□●●□□□●□▲◆ + ▲□●□△◇□□□■◇□◇△■◇□●◆ + ◆△△◇●□□◇○□■◆ ◆■□●□△■◇□▲ + ▲△△□△▲●▲◆ □□◆○◇◆ + △△◇◇△△○ ■○□▲ ◇○□◇▲ + ●△●△□◆ ■◇◆■△△□◆ ◇○○◇ + ○ △△ ○○ ■□◆△ ◇○○○ + ◇◇◇△ △○○●△■ △ ■△ + △ ▲◆ ▲◇◇△●■△◇●●◆●◆●□◇◇ ◇◇●□ + ◇▲○●■ △△●■□□△◇● ○▲◆◆◆◆△■◇△■ ◇■ + △◆○▲■△○◆◇△○◆ ◆■○○○○□○○□△△◆▲◇ + ■△○○△◇ ◆△□ ▲○ + ○●◆○●▲△▲ ▲●△◆□□■ + ■○◆■◇□◇▲△◇◇□◇●△□■◆△●■ + ■○◇●●◆◆△◆◆□□□■○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_36.txt b/codex-rs/tui2/frames/shapes/frame_36.txt new file mode 100644 index 00000000000..0cac995ed7a --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_36.txt @@ -0,0 +1,17 @@ + + ◆◆□●□□□□●□▲◆ + ◆●■■○□△●△□○□●□□■□□□◆ + ▲○■○▲□□▲□■◆◆ ◆■□◇□□◇▲□◇▲ + ◆◇■□□◆◆◆◆ ■□◇○■◇ + ▲△◆●○■◇○◆○▲△ ○○△□ + △ ▲△◆ □◆ ■◇△▲ ■○○□ + ◇ ● ◆ ○ ■○○ ◇○◇▲ + ◇◇○◇ ◆○ ◆△△ ○◇ ◇ + ◇■■◇ △△ ▲□■■▲□□◆□□□□□◆ ◇○◇◇ + ◆□◇○ ▲◇◆◆△◇△ ◆◇◆◆◆◆◆◆◆◇◇ ○△■◆ + ■ □◇○ □◆□△○ ■□□○○○○○■ ◇▲■△ + ■▲■○●△ ▲△◇■△ + ○△◇◇□◇▲ ▲□□■△▲◆ + □▲◆■□□●○●◇◆□◇●■▲■▲●□■ + ◆□□△◇◆▲▲◆▲▲□□□□■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_4.txt b/codex-rs/tui2/frames/shapes/frame_4.txt new file mode 100644 index 00000000000..31e55f9cb8c --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_4.txt @@ -0,0 +1,17 @@ + + ◆△●●□●●□●□▲◆ + ◆▲■□□◇△ ◇○●△□●◇■△□▲◆ + ▲□●◇◇◇□□■◆ ◆○◇○○□◇△○○◆ + △△◇▲◇■△□▲ ■◇□□○□○ + △◇△◆◇●◇○●○◇▲ ○■○◇□○ + △◇◇○◇ ■◆◇◇△□○ ■ ○●◇▲ + ■○◇●◆ ■□□○□■◇▲ ▲ ■●◇ + ◇△○ ▲◇△□○◇◇ ◇◇◇ + ◆◇ ◇◆ ●◇△■○△△□◇□□□◆◆◆◆▲▲ ◇◇□ + ◇○△ ◇ ▲△△△▲◆◇◆●○◇▲△◆▲△△○●◇△△■◇ + ◆◇●■○▲○○▲○△ ■□□□□□□□■○▲◇◇◇◆ + ▲◇○■◇◆◆ ▲○●△□□■ + ○○▲△○ ▲◆ ◆△○▲□●◇△ + ◇◇◇●◆□□○●●●●▲□●□□○◇□◆ + □○○◇△◇◆◆◆◆◆●▲□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_5.txt b/codex-rs/tui2/frames/shapes/frame_5.txt new file mode 100644 index 00000000000..a8ae0ab8193 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_5.txt @@ -0,0 +1,17 @@ + + ◆△●●●□●●●□▲◆ + ◆□■□△□△◇◇□◇●□●◆◇■□▲ + □◆△○□◇□■◆ ■○●◇□○ ▲□▲ + ▲◇△◇△◇△●▲ ▲○◇◇◇◇◆ + ▲○△□△○◆□◇◆○▲ ◆ ○◇○◇◆ + ◇◇◇◇△ ○◆◇○●△◇◆ □△◇◇ + ◇△◇ □ ◆○◆◇○■◇▲ ■■◇□◇▲ + ◇△◇◆■ ▲◆●◇△◆◇ □◇◆◇ + ◇▲◇○□ △◆◇■▲◇□●◆◇□◇◆◆◆□▲◆■◇△◇ + ◆○◇○▲▲△□□■ ◇■ △△○●○▲●△▲△◇△□◇◆ + ○▲◇◇▲◇▲○○△□◆ ■□□■■■■■▲◇●◇○△ + ○○□△○○ ◆ △◆△□▲○ + ○△□○◇●□◆ ◆●□▲□■●□ + ○◇□◇▲□□○●◇●◆▲□●□○◇●□ + ○▲□◆□■◆◆◆▲◆▲□□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_6.txt b/codex-rs/tui2/frames/shapes/frame_6.txt new file mode 100644 index 00000000000..e0b1f854547 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_6.txt @@ -0,0 +1,17 @@ + + ◆△●●●□●●□□◆◆ + ▲○●△□◇●□□◇□□●◆▲□□◆ + ▲▲▲◇◇◇□■ ◆■◇△●□□□△○◆ + △▲◇□◇□◆●▲ ○□◇◇◇◇▲ + ■●◇◇◇□○○□□○ △○○◇○▲ + ●▲△◇△◆○□●◇○◆◇▲ △●○△◇ + ◆ □◇◇ ○○◇△○○ ○□◇◆◇ + ◇ ◇◇◇ ◆◇●◇△◇◇ ◇□○◇◇ + ◆△◇▲◇ △■◇◇□●△□◆◇□◇◆◆□□◆△○△◇ + ◇◇○▲△◆○◆●□ ◇△●□▲◇◇▲●●●○◇▲◇○ + △ ◇ △◇◇◇●△■ ■□■■■■□■◆○○△◇ + ▲ ● △▲◆ ▲△◆△△◇◆ + △◆○◆□□▲ ◆□○◆△□▲□ + ◆■◇□▲■□ ●◇◆◆▲□□□ △●■ + ○▲●□□▲◆◆◆◆◆▲□□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_7.txt b/codex-rs/tui2/frames/shapes/frame_7.txt new file mode 100644 index 00000000000..7e69d68d573 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_7.txt @@ -0,0 +1,17 @@ + + ◆△◇●□□●●□▲◆ + ▲□◆□○◇□□□●●○● ◆□▲ + □ ▲◇◇◇□◆ ◆○◇◆ □△◆□▲ + ○○◇◇◇◇●□ ○◇○□◇△▲ + ◆■◇□◇◇○◇△○▲ △ □◇◇○ + ◇ ●◇◇■ ◆■○●◇○ ▲▲◇◇◇ + ◆○■◇ ○◆◇○◇○◇ □□○◇◇ + ◆■△◇◇ ●□△◇□◇◆ ■ ◇◇◇ + △■◇□ ▲■◆△◇○△◇◇●●◆◇◇●●□○◇◇ + ◇▲■◇◇●△◇◇△■◇△○◇◇○○▲●▲●○◇○◇ + ○ ●○△□◇□◆△ □□□□□□■■◆△◇◇ + ■◆ △◆△◆◆ ▲■▲△●△■ + ■●▲□○○○◆ ▲○◇▲□△□■ + ○◆■○◇□○●◇◆◆◆□□◇□□△ + ■◇□■◇△◇◆◆▲◆●□□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_8.txt b/codex-rs/tui2/frames/shapes/frame_8.txt new file mode 100644 index 00000000000..b7bddd4156a --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_8.txt @@ -0,0 +1,17 @@ + + ◆◆□◆●□□●□▲◆ + ▲■□△◇◇◇□●● ◇ ■□ + ◆◇●▲◇△△□ ▲■◇△◇□○□○▲ + ◆□○□△◇▲□◇ ■ ●○◇◇○ + ■ △◇△▲○◇△○ △■○◇◇▲ + ◇■▲◇○○○◆○◇◆□ ○□◇◇◇ + ◇□◇◇◇ ■◆●○◇◆◇ ◇○◇△◇▲ + ○▲◇◇△ ◇□○△◇▲□△ △◆△◇◇ + ○ ○◇◇◆○ △□●◇◇△□▲□▲▲○◇◇◇ + ○□△●◆◇■▲◇▲◇◇●◇●◆◆◇◇●■ + △△○○◇○◇◇◇△■○□○□■○□■△○◇ + ■▲■◇○◇◆◆ △▲△△△■ + ■○◆□○○○◆ ◆△○◆◇□△□ + △△■◇▲□ ●●□□●△●▲◇◆ + ◇◇◆□□○◇◆▲□△●◆ + ◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_9.txt b/codex-rs/tui2/frames/shapes/frame_9.txt new file mode 100644 index 00000000000..4342d3c81e5 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_9.txt @@ -0,0 +1,17 @@ + + △●○□△□□●▲ + △□◆△◇◇□■● ◆▲○◆ + △ ▲◇◇△□○○●△ ○◆◇▲ + ■ ◇◇△●●◆ ◆○▲◇○○◇□▲ + △□■●○□■◇□▲ □△■□△□◇◇ + ◇ ◇◇◇■○◇○●◆◆ ◇■◇□◇◇◇ + ◆ ◇□○◇□□◇○○▲■△▲△ ◆◇◇ + ◇ ◇△◆▲○◇◇◇◆△●○◆◇ ◇◇ + ■▲◇■ △◇■◇◇◇◇□◇◇▲◇◇◆ + ◇◆◇◇◇◆▲◇○◇◆◇◇◇◇◆◆◇◇◇ + ■ ◆○△○○◇△■□□□□◇◇■△○◇ + ○◇◇◇○▲◆ ▲△ ○△◇◆ + ○□○◇○○ △●◆●◇△□ + ○▲■○◆◇□△□●●●□◇◆ + ■▲◆ □○○◇●□△■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_1.txt b/codex-rs/tui2/frames/slug/frame_1.txt new file mode 100644 index 00000000000..514dc8ac49c --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_1.txt @@ -0,0 +1,17 @@ + + d-dcottoottd + dot5pot5tooeeod dgtd + tepetppgde egpegxoxeet + cpdoppttd 5pecet + odc5pdeoeoo g-eoot + xp te ep5ceet p-oeet + tdg-p poep5ged g e5e + eedee t55ecep gee + eoxpe ceedoeg-xttttttdtt og e + dxcp dcte 5p egeddd-cttte5t5te + oddgd dot-5e edpppp dpg5tcd5 + pdt gt e tp5pde + doteotd dodtedtg + dptodgptccocc-optdtep + epgpexxdddtdctpg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_10.txt b/codex-rs/tui2/frames/slug/frame_10.txt new file mode 100644 index 00000000000..bd3b8fafff4 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_10.txt @@ -0,0 +1,17 @@ + + dtpppottd + ppetptox5dpt + ddtee5xx-xtott + edd5oecd-otppoot + 5 ceeged pt5d5e5 + ee pepx55o gedge + o xpgpeexep e5t + g eeot5tee-de-oee + g xo ooecxxtotcee + e teoted5dpdddepe + t geeeeegggotgoee + oeptotpg dxggt55 + ep eeexptct5e5e + cepp5etcdg55p + pt dpodtcp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_11.txt b/codex-rs/tui2/frames/slug/frame_11.txt new file mode 100644 index 00000000000..9eaf147a6a0 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_11.txt @@ -0,0 +1,17 @@ + + tppppttd + 5g ceeeoot + 5gddeecop5ot + eddeeoctdo55 + dg-coetopcdeet + eteeetdcced5ee + pp teeeeeedeoo + e ee5eeo5 ege + 5 pe5eep5tede + pp de5otg5eded + pe- eeeeo5eooe + od-5edp5ppcee + gd peooddecg + otgpeetd5pe + od pptdte + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_12.txt b/codex-rs/tui2/frames/slug/frame_12.txt new file mode 100644 index 00000000000..11163a99b9b --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_12.txt @@ -0,0 +1,17 @@ + + tpppt- + toed5eto + g e5ott + 5txeeooge + e pxeee-5e + ep--pdgdeg + e x5oeeo + e 5toeeg + pt x5eetex + e 5epcpd + e- egopp5 + t pegdte + 5 ppetpoe + pd5gteoee + o pxo-5 + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_13.txt b/codex-rs/tui2/frames/slug/frame_13.txt new file mode 100644 index 00000000000..eb072e40ad2 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_13.txt @@ -0,0 +1,17 @@ + + 5pppt + eddee + eedeg + epped + p ee + gc-ee + t ee + t ge + 5t dx- + eg toe + pe-- xe + eddde + etted + pddeo + t -go + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_14.txt b/codex-rs/tui2/frames/slug/frame_14.txt new file mode 100644 index 00000000000..100f3093023 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_14.txt @@ -0,0 +1,17 @@ + + tpppc + t5dd-o + edee- e + ee5egdxt + eeeo e + xpee pe + eeee - p + eeoee o + eeex g + gd55 c5 + oeggt-te + epxeddde + 5ggeoooe + eo5pdd5 + dp po5 + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_15.txt b/codex-rs/tui2/frames/slug/frame_15.txt new file mode 100644 index 00000000000..5761f309d46 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_15.txt @@ -0,0 +1,17 @@ + + ttpppxd + etoeedcpt + 55epooegpe + e5e 55t-dde + eeogooee5gde + oee-ee55e g + ee eeeexxte + ee5xeteee p-e + eetttpeeed-ce + edec5eoxp- -e + e5eeeede e c + epp-dxeo- o + peot 555e ce + edeeoo-to5 + odpdddd5 + ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_16.txt b/codex-rs/tui2/frames/slug/frame_16.txt new file mode 100644 index 00000000000..f9001140ed8 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_16.txt @@ -0,0 +1,17 @@ + + dotgpptxd + 5deepeeoeoo + e55go5ooee po + 555 cpdoeoeeppe + eecd5ppoeep5eeo + eeepep eoo ge x-e + ooe 5eteeee5pgt e + e5c eeee5eeegc ee + ooexetx5deegpt 5 + pgddtooope-de tde + eeetgg5poecgc-xee + eep ee e55t 5 + oep e 5t5dte + oexgdpx55tde + pc-docddcp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_17.txt b/codex-rs/tui2/frames/slug/frame_17.txt new file mode 100644 index 00000000000..696d932d409 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_17.txt @@ -0,0 +1,17 @@ + + totttccxtd + dcppexxpopetgdt + tpo5dooettgeeedgo + 5e5d5egde pecoxeeoo + e5d x eg5ooo55 t + eopt5e tc5e 5to5e-5 + pgc5e t55ed pgee5oe + -goeg g55ee5eteeocp + t5p5oootxodeodcoeee e + egdcdde5po5eeogotpto + ooo5gggppppodep55o op + oeedp e5eee c + doogpod tpt5dd5 + t xptootedcpcep + etc5dttxpdtp + e \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_18.txt b/codex-rs/tui2/frames/slug/frame_18.txt new file mode 100644 index 00000000000..abb0da53d29 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_18.txt @@ -0,0 +1,17 @@ + + dootootcxtd + dtgdctttxddtgtccd + d5cepgpogtg-dgt55o p + teep-tdgp poed5oxocooot + do55 5e dgtdo5x5edocood + 5oe ttx-ddd5tptep-5d5e5xg + eeoc5 t5p5egd5gpeoeot + go-xe dogeecd eceg + ogg tooococtxetep5ot epee + dooepop xe5ddodxeedcxeo t + e5eoeggpppppe odd5e5p5e-e + oogtot eepg5pdp + odptdd- dpdd5ote + pe-ppttooodppd5dtp + gxed5ddt-tgctg + e \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_19.txt b/codex-rs/tui2/frames/slug/frame_19.txt new file mode 100644 index 00000000000..ffc4d2b4755 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_19.txt @@ -0,0 +1,17 @@ + + ddootxtoox-d + dteeeo5ooodpdteptt + tpcc5getpe epctepco + 5ceptde doottdept + ee5e5e tedg5geo eot + eo5pp depx5g-5 p-pe + doexp 5pd5ette 5c5te + eeee ecgoegt e eee + epeotoccoooxxxetpcpec o gee + dc5teop5dptotet dd codot5-ed + pog5tegggggppg dod5e55 55p + oodpdo e55d55p + pgdoxxpt tco-5ece + pg-ep5xtddoc5pg cpxp + gx-dc-pdt-dp-d + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_2.txt b/codex-rs/tui2/frames/slug/frame_2.txt new file mode 100644 index 00000000000..f4419e3d693 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_2.txt @@ -0,0 +1,17 @@ + + d-dcotooottd + dtt5pcteexoxeodpeptd + tepeoppxpee egpop5eecet + de5d5ppttd -toe5et + tdg5pdeodood dteoet + p5tge epot5ot ooepe + teppe d5ecedet 5gege + eg oe tepeecp ep5-e + pggoe cedddeg-xtttttttttedexp + dope 5eep 5p eoodddd--ddet5geg + ooo p po--ep egpppppppgetpee + pod-5t e ttc5tp + -oddett todtgdeg + exdcddgptccocc-opedeep + eptptxxddddxc5pg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_20.txt b/codex-rs/tui2/frames/slug/frame_20.txt new file mode 100644 index 00000000000..0039bd880b1 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_20.txt @@ -0,0 +1,17 @@ + + ddtoxxototdd + tcogctdtoooeeddpott + ttgdtpgxpg egdgotpetd + degdpep dtteo5poo + de tep d5gdeedpoeeo + 5etep tppceg5 poxeo + cecte tp -tpd toe5 + edd5e ggccegt exge + e5eectocoooooodtpcddoop 5 eo + pededg 5eeddddeo poogeoo t5 ee + otdopgggggggpg otoeete 5o + p dp5t d5p-e5 + oddptxd tcpecpp + dt--gxtdcctcgxget5pp + eptxdgoddddepgg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_21.txt b/codex-rs/tui2/frames/slug/frame_21.txt new file mode 100644 index 00000000000..87e3597d5d8 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_21.txt @@ -0,0 +1,17 @@ + + ddtotootottdd + ttpeeddtxoxtcde-ptd + cpddtpdge edxptdept + tpecgp dtcptcpt + 5t5pe te5do ooddt + 5g5e tppd55 oodt + epee dg5et5p ocog + eo oc get e e + e e ttcccccttt detget c5 e + e xo ed dte dgepet 5g-5 + c g- eeggg pe ppdtc 5e t + pt ccd dpg 5 + pd d-d d-cpp te + pod pgptcxxccopgg -e + pttctddddtdctpe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_22.txt b/codex-rs/tui2/frames/slug/frame_22.txt new file mode 100644 index 00000000000..8dfe7daaab6 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_22.txt @@ -0,0 +1,17 @@ + + dttotootottd + dt5pe-d5e5oeecet5ptd + tcpcxoppe egpopptddtt + cpxppg tteeedpo + oex5p td5eoe-5cpe + oep5e tx5ecd5e oeooe + etpo5 xteop5p xe5e + eeoee eoexdo edege + eetgt txoocccottdopedgot decgg + deo pdootg5tgx55e opcdgettg5oo + ooode gggggppge ecptd555gte + opodot dptgetp + pgtc ttd d-ptgpd5 + pcpttgpxcotcc5opggptp + gxgxdcddd-od-ope + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_23.txt b/codex-rs/tui2/frames/slug/frame_23.txt new file mode 100644 index 00000000000..f573acb7142 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_23.txt @@ -0,0 +1,17 @@ + + dttdotpttotdd + doeteodtdootedepetd + tgteptcpe gxcteoceet + 5cepc5e dccoeeco + 55ee5p t5ttpp5poeo + toeg5e dppppp5ppexoet + eeege 5c5- dee ggepp + x5e e egetdot e p5e + dgo etxcooooocoedpedgod e exe + eoodegog 5eo oedotx ode + 5pe e eggggggg pe5coed5d5e + teede- t5eod5e + o etp5dd d-gcp5t5 + oootcptoxoodttg-dtpe + gxgpooxddtddppe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_24.txt b/codex-rs/tui2/frames/slug/frame_24.txt new file mode 100644 index 00000000000..92833e8c589 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_24.txt @@ -0,0 +1,17 @@ + + dxdcttpootd + tgd5geddxoottdpt + tgpco5tgx -ecpxppt o + tp55t5eotoex5egdpoeodp + t 55o5t egg5odeeeoetp + xtotoe ptt5g-ecg5go + e exg t5dt5g5doeoded + e oee eto5odexd5-eee + e- eccccttxxxttdptde e ee + d 5 gpe5ttcctte-dotpe epe + o poeopgggggge ot-poeo5te + o otpo egg5c5 + o-poee- dogd5cdp + --ptodgxxcoocedd5e + pcdptcoddootp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_25.txt b/codex-rs/tui2/frames/slug/frame_25.txt new file mode 100644 index 00000000000..d8b8655dacf --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_25.txt @@ -0,0 +1,17 @@ + + dtopcttttd + tgptpedcoepeet + 5e55ttg-etoooeeed + etpe 5g oe goetpo5 + tddot5pdc5deg e55o5p + otxexdpt-dec 5ete55et + e5epe edd5od5eo5dgeoe + eee5e-ggxdoo5eodxoeeo + dtoegeddooootxeooetpeo + 5-ge5etedeecpdeopo5oe + oxp5oeegggggpt5eoe5ee + edgectco-tpcd5t55e5 + dededodpc5td5dee5 + o-coodpoeppgpep + xtgpottdtep + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_26.txt b/codex-rs/tui2/frames/slug/frame_26.txt new file mode 100644 index 00000000000..4be73d44de0 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_26.txt @@ -0,0 +1,17 @@ + + dcpppgtt + 5pec5oetcet + 5pggecoeoggot + cpgteexdeedt5d + edtoe-xgpo5ceoc + o ge5c5gpdogoo5 + eg ppoee5eeccgt + 5- oeeeddoee5ee + e -opctxtoo5oee + g -de5teddtpoope + eeg5epgot5etoee + odxe5o 55geee + p peo de5e5t + egpoogpdppc + o5cdpxdop + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_27.txt b/codex-rs/tui2/frames/slug/frame_27.txt new file mode 100644 index 00000000000..f333909d2b7 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_27.txt @@ -0,0 +1,17 @@ + + cppptt + ecc5e5o + cpe pe5pe + exxdeecex + e eed-po + xd-dgeeeee + o geedpeeg + e eeexogee + e- -geteeee + po -gdedpee + e- ddppt5p + eetteed5e + eootot5ed + oddeoo55 + pog do5 + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_28.txt b/codex-rs/tui2/frames/slug/frame_28.txt new file mode 100644 index 00000000000..3c0deb542c8 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_28.txt @@ -0,0 +1,17 @@ + + 5ppc + etdee + o cee + e-epe + e xe + e -ge + dex de + e-gge + 5o de + ee-cxe + e de + eotoe + eopee + pdd5e + x -te + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_29.txt b/codex-rs/tui2/frames/slug/frame_29.txt new file mode 100644 index 00000000000..0c6277f4d52 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_29.txt @@ -0,0 +1,17 @@ + + tppppt + tep5gpo + dtee pge + eeeedot5 + o xge e + etxee dd + eooeee d + eeoxe + eexpe e + deoee e + pee5ed o + e5xxe de + xeeeexde + eoep5gep + xep -t + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_3.txt b/codex-rs/tui2/frames/slug/frame_3.txt new file mode 100644 index 00000000000..b1e91736085 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_3.txt @@ -0,0 +1,17 @@ + + d-octtooootd + dtt5oeetegooecddeptd + tc5pcepgge egxgppt5det + 5t5oecttd eopgeeo + p5pe5d5eepod odoeed + 5eoo5 -teocoo e ooe + op e ppoedget -p5et + t-ete t-eg5oe xdeoe + -gpe ceptxep-xottdddttdxdgce + tocot -5p5cce epeddtgo-tcoeeoee + pde e geettg gpppgggppt555t5 + ppx ot e 5dtd55 + od55pot ttc5gtep + odttdppdococtcopcedtg + eptpdcxddddxc5dg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_30.txt b/codex-rs/tui2/frames/slug/frame_30.txt new file mode 100644 index 00000000000..9dfd28bc20d --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_30.txt @@ -0,0 +1,17 @@ + + dcgpptt + d5ceeoppoo + gpdetooetto + eeoe5edoeo ot + opeeeoetet e + epedt peeee-e + o5eeeod o5oe oe + ge ptoxtege- e + gedgoxoxo5et e + geeoeddxegtt e + goeecodpxeetxe + ep55t5poeeg e + oeoee5pxetx5 + tdttod5et5p + o--edddcp + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_31.txt b/codex-rs/tui2/frames/slug/frame_31.txt new file mode 100644 index 00000000000..1dba8edd8f7 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_31.txt @@ -0,0 +1,17 @@ + + d-cptptot + dtpcttdgtoppt + teo55tode-gedpo + tx5tddpcdtooeoxeo + deeeet-podtgoe5dd + cedexdepgocpt-5etge + 5ee edpeo-o5cpepe5e + oot exgeo edggexo-e + eotdepdxex5txxed e + geex55eedddodeoee p + dpd5tet 5pppe5epxdg + eeeooot dop e + cepodgt - epe5e + ceoe5deetegtee + pgoxdtp5-cp + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_32.txt b/codex-rs/tui2/frames/slug/frame_32.txt new file mode 100644 index 00000000000..33160e71634 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_32.txt @@ -0,0 +1,17 @@ + + tttttccxtd + cpxppxoeeppext + 5dpodttdc-epepoppt + p55edtec- - tdoettgt + 55etepoppec petxo5opot + o5ootopeeceetd et5 p + pegctepoeettetoe5d55epd + eeetd gdeeg5pec-dgoegge + oee-d pdegddetttxxxoegoe + pggdeepopodddddeepecx-d + topee5 epggpppdc555-5 + otedoo toe5px + pppeextd d5de5 t + o-ogxtecopedtgd5 + xcoddtt5pdgg + eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_33.txt b/codex-rs/tui2/frames/slug/frame_33.txt new file mode 100644 index 00000000000..ff8827f3d2f --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_33.txt @@ -0,0 +1,17 @@ + + dttototox-d + dtpeeopoxce-epep- + d5oetpttoge gp5eetgt + to5e5detd peeodp + d-e5eee5odo etood + 55t odooeot 5o5do + eet g otpedeo epee + pett tppo5ot ogep + eee e te 5ptttcootxxt5deox + d5epg5ct5exedddddeepdddxe- + ooot e5e5 5 ggggppp eet5de + otoptgt 55c5o5 + pogptpct dtet5pop + oxgpotetctdgctopxp + goottddddtpc-g + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_34.txt b/codex-rs/tui2/frames/slug/frame_34.txt new file mode 100644 index 00000000000..4b1eb6a5a23 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_34.txt @@ -0,0 +1,17 @@ + + dtottttoxtd + dtpt5ocegxtpoppctod + d55tepgcxpe edootgppt + t5depd5td petpoo + 55eo-5egeggt oopod + t5oee5 oogeopo otoeo + epdxd podpedd ecxd + oeogc epde5ge 5 do + tp5-g 5o55gdoottttttxddg xde + eeot5ttdg55pxeedx-dddt5deeeeo + 5dot eoepdg gppeeeed t5toep + 5tocood 5c-e5p + oteetoct dtogd5te + -o5xgettcttopopetpcp + gxocodddddcopod + eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_35.txt b/codex-rs/tui2/frames/slug/frame_35.txt new file mode 100644 index 00000000000..f2432dc0adf --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_35.txt @@ -0,0 +1,17 @@ + + dttcotttottd + tpop-xpptgepxcgxpod + d55ectpedpge egpop-gept + t55t5tctd ptdoed + 55xe55o gopt eopet + c5c5te pedg--pd eooe + o g-5 oo ppd- edoo + xexc 5ooc5p 5 g5 + 5 td tee5cg5eoodcdotxx exop + etdcp 55cgpt5ec otdddd5gx5p ep + 5eotp5ode5de egddddpddp55dte + g-do5x d5p td + ocedctct tc5dppp + gddgxpx-cxxtxc5pgd5cg + gdxcodd5ddttpgd + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_36.txt b/codex-rs/tui2/frames/slug/frame_36.txt new file mode 100644 index 00000000000..c84a104e4ac --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_36.txt @@ -0,0 +1,17 @@ + + ddtottttottd + doggot5c5totcttgpptd + topottp-pgee egpxptetpet + degptdddd ppxoge + t5dcopeoeot- do-p + 5 t5e pd ge5t godp + e cge go goo edet + eeox do d55g oe e + epge 55 tpgptttdtttttd eoxe + dpeo tedd5x5 gexdddddddee o5pe + p peo tdt5d gppdddddg etg5 + ptgoc- t5eg5 + o5eetxt tttg5te + ptdgppodcxdtxcg-gtctp + ept5xdttdttttppg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_4.txt b/codex-rs/tui2/frames/slug/frame_4.txt new file mode 100644 index 00000000000..2eed2c84653 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_4.txt @@ -0,0 +1,17 @@ + + d-octootottd + d-gtpe5geoo5pceg5ptd + tpoeeetpge edxodpe5ood + 55eteg-tt geppopo + 5e5deoeocdet ogoepo + 5eede pdee5po p ooet + goece pppotget t gce + e-o te5pdee eee + deged ce5gd55txtttddddtt eep + eo5ge t555tdeeooet-dtc5dce55ge + eecpotooto5 gppppppppd-eeee + teogede tdc5ppp + ootcdgtd d-dtpce5 + xeeodppoccocttoptoepe + pooxcxdddddc-pe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_5.txt b/codex-rs/tui2/frames/slug/frame_5.txt new file mode 100644 index 00000000000..e0c7693a9ec --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_5.txt @@ -0,0 +1,17 @@ + + d-occtooottd + dtgt5p5eetxotcdegtt + pd5otepge gdoepogtpt + te5e5e-ot -deeeed + to5p5ddtedot e oeoed + xeee5 odeoc5ed t5ee + e5egt eodeoget ppxtet + e5edg tdoe5de tede + etedp 5degtepodxtxdddttdge-e + doeott5tpg egg55oodto-t5e5pee + oteetxtoo5te gppgggggtxceo5 + oot5oo e 5d5ptd + d5poeotd dottppcp + depetptocxodttopdxcp + d-pdpgddd-dttpe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_6.txt b/codex-rs/tui2/frames/slug/frame_6.txt new file mode 100644 index 00000000000..d5ac091f39c --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_6.txt @@ -0,0 +1,17 @@ + + d-occtoottdd + tdc5peoptettcdtptd + ttteeepg egx-optp5od + 5tepepdot oteeeet + poeeepootpo -ooeot + c-5e5edtceodet -oo5e + d txe gdoe5do dtede + x exe deox5ee xtoee + d-e-e 5peepc5tdxtxddttd-o5e + eeot5dddct e5opteetcoooeteog + 5 e 5xeec5g gpgggppgeoo5e + t c 5te tcd55ee + -eodppt dtdd5ptt + egxptgtgcxddttppgccp + d-cpttdddedttp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_7.txt b/codex-rs/tui2/frames/slug/frame_7.txt new file mode 100644 index 00000000000..02d1f1ae521 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_7.txt @@ -0,0 +1,17 @@ + + d-xcptoottd + tpetoetptooocgept + p teeepe edxegp5dpt + ooeeexct dxope5t + epepeede5ot - peeo + e ceeg epoceo t-eee + eoge ddeoeoe ppdee + dg5xe ot5epee p eee + 5gxp tgd5eo5xxccdxxocpoee + etpeeocxe5pe-oeeoototcoeoe + o od5pepd5 ppppppggd5ee + pd 5d5de tg-5c5p + pcttoood tdxtp5pp + odpoepocxdddtpept5 + gxpgxcxddtdcpp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_8.txt b/codex-rs/tui2/frames/slug/frame_8.txt new file mode 100644 index 00000000000..d028ab360ee --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_8.txt @@ -0,0 +1,17 @@ + + ddtdcttottd + tgp5eeepoogx gt + deote55pgtgx5xpotdt + dpop5ette p odeeo + p 5e5toe5o -poeet + epteodddoedp oteee + epeee pdcoeee ede5et + otee5 eto5etp- -e5ee + o oeedd g5poex5tttttoxee + g op5odegteteeceoddeeop + 55ooeoeee5pdpdpgopg5oe + ptgeoeee -t555p + podpoood d5odet5p + -cpetpgcctpc5otee + xxdppoedtt5oe + ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_9.txt b/codex-rs/tui2/frames/slug/frame_9.txt new file mode 100644 index 00000000000..2481e07a357 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_9.txt @@ -0,0 +1,17 @@ + + -odp5ttot + 5pd5eepgogd-od + 5 tee5pddo5godxt + g ee5cod ddteodett + 5pgcopgept p-ptctee + e eeegdeocdd epepeee + e xpoeppeootg-t5 eee + e x5dtoxeed5oode gee + g gteg 5egexxetexteee + edeeedtededeeeeddeee + g ed5ooe5gppppeeg5oe + oxeeote t5 d5ee + otoeoo 5cdce5p + d-godep5toccpee + p-d pooect5g + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_1.txt b/codex-rs/tui2/frames/vbars/frame_1.txt new file mode 100644 index 00000000000..0ca3a5d334c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_1.txt @@ -0,0 +1,17 @@ + + ▎▋▎▋▌▉▉▌▌▉▊▎ + ▎▌▊▋▉▍▉▋▉▍▌▏▏▌▎ ▎█▉▎ + ▊▏▉▏▉▉▉█▍▎ ▎█▉▎█▏▌▏▏▏▉ + ▌▉▎▍▉█▊▊▎ ▋▉▏▌▏▊ + ▍▍▌▋█▍▏▍▎▍▍ █▋▏▍▍▊ + ▏▉ ▉▎ ▏▉▋▌▏▏▊ █▋▍▏▏▊ + ▉▍█▊▉ █▍▏▉▋█▏▎ ▏▋▏ + ▏▏▎▏▎ ▊▋▋▏▌▏▉ █▎▏ + ▏▌▏█▎ ▌▏▏▍▍▏█▋▏▉▉▉▉▉▉▎▉▊ ▌█ ▏ + ▎▏▌▉ ▎▌▉▎ ▋▉ ▏█▏▎▎▎▋▋▊▊▊▏▋▊▋▊▏ + ▍▍▎█▍ ▍▍▊▋▋▎ ▎▍▉██▉ ▍▉█▋▊▌▎▋ + ▉▍▊ █▊ ▎ ▊█▋▉▎▏ + ▍▍▊▎▍▉▎ ▎▌▎▉▏▎▉█ + ▍▉▊▍▎ ▉▉▋▌▌▌▌▋▌▉▉▎▊▏▉ + ▎▉█▉▏▏▏▎▎▎▊▎▌▉▉█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_10.txt b/codex-rs/tui2/frames/vbars/frame_10.txt new file mode 100644 index 00000000000..b422fb1274e --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_10.txt @@ -0,0 +1,17 @@ + + ▎▉▉▉▉▍▉▉▎ + ▉█▎▉▉▉▍▏▋▎▉▊ + ▍▎▊▏▏▋▏▏▊▏▉▍▊▊ + ▏▎▎▋▍▏▌▎▋▍▊██▍▍▊ + ▋ ▌▏▏█▏▍ █▊▋▎▋▏▋ + ▏▎ █▏▉▏▋▋▍ ▎▎█▏ + ▍ ▏▉█▉▏▏▏▏▉ ▏▋▊ + █ ▏▏▍▉▋▉▏▏▊▎▎▋▍▏▏ + █ ▏▍ ▍▍▏▌▏▏▉▍▉▌▏▏ + ▏ ▊▏▍▊▏▎▋▎▉▎▎▎▏▉▎ + ▊ █▏▏▏▏▏██ ▍▊█▍▏▎ + ▍▎█▊▍▊▉█ ▎▏██▊▋▋ + ▏█ ▏▏▏▏▉▊▋▊▋▏▋▎ + ▌▎▉▉▋▏▉▌▎ ▋▋█ + ▉▊ ▎▉▍▎▊▌▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_11.txt b/codex-rs/tui2/frames/vbars/frame_11.txt new file mode 100644 index 00000000000..5d4524e2938 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_11.txt @@ -0,0 +1,17 @@ + + ▊▉▉▉▉▉▉▎ + ▋█ ▌▏▏▏▍▍▊ + ▋█▎▎▏▏▌▍▉▋▍▊ + ▏▎▎▏▏▌▌▊▎▌▋▋ + ▎█▋▌▍▏▉▍█▌▎▏▏▊ + ▏▉▎▏▏▉▎▌▌▏▎▋▏▏ + ▉▉ ▊▏▏▏▏▏▏▎▏▌▍ + ▏ ▏▏▋▏▏▍▋ ▏█▏ + ▋ █▏▋▏▏▉▋▉▏▎▏ + ▉▉ ▎▏▋▌▊█▋▏▎▏▍ + █▎▊ ▏▏▏▏▌▋▏▍▍▏ + ▍▎▊▋▏▎▉▋▉▉▌▏▎ + ▎ ▉▏▍▍▍▎▏▌█ + ▍▉ ▉▏▏▊▎▋▉▎ + ▍▎ █▉▉▎▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_12.txt b/codex-rs/tui2/frames/vbars/frame_12.txt new file mode 100644 index 00000000000..f81900edb1c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_12.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▋ + ▊▌▎▎▋▏▊▍ + █ ▏▋▍▉▊ + ▋▉▏▏▏▌▌█▏ + ▏ █▏▏▏▏▋▋▏ + ▏█▋▋▉▍█▎▏█ + ▏ ▏▋▍▏▏▍ + ▏ ▋▉▍▏▏█ + ▉▊ ▏▋▏▏▉▏▏ + ▏ ▋▏▉▌▉▎ + ▏▋ ▏█▌▉▉▋ + ▊ ▉▏ ▎▊▏ + ▋ ▉▉▏▊▉▍▏ + █▍▋█▊▏▍▏▎ + ▍ █▏▍▋▋ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_13.txt b/codex-rs/tui2/frames/vbars/frame_13.txt new file mode 100644 index 00000000000..4231032a45c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_13.txt @@ -0,0 +1,17 @@ + + ▋▉▉▉▊ + ▏▎▎▏▏ + ▏▎▎▏█ + ▏▉▉▏▎ + ▉ ▏▏ + █▋▊▏▏ + ▊ ▏▏ + ▉ █▏ + ▋▉ ▎▏▋ + ▏█ ▉▌▏ + █▎▋▋ ▏▎ + ▏▎▎▎▏ + ▏▉▊▏▎ + ▉▎▎▏▌ + ▊ ▋█▌ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_14.txt b/codex-rs/tui2/frames/vbars/frame_14.txt new file mode 100644 index 00000000000..6eab794e0ab --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_14.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▌ + ▊▋▎▎▋▍ + ▏▎▏▏▋ ▏ + ▏▏▋▏█▍▏▊ + ▏▏▏▍ ▏ + ▏▉▏▏ █▏ + ▏▏▏▏ ▋ ▉ + ▏▏▍▏▎ ▍ + ▏▏▏▏ █ + █▍▋▋ ▌▋ + ▍▏██▊▋▊▏ + ▏▉▏▏▎▎▎▏ + ▋ █▏▌▌▌▎ + ▏▍▋▉▎▎▋ + ▎▉ █▌▋ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_15.txt b/codex-rs/tui2/frames/vbars/frame_15.txt new file mode 100644 index 00000000000..fa9a859bd04 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_15.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▏▎ + ▎▊▌▏▏▍▋▉▊ + ▋▋▏▉▌▍▏█▉▏ + ▏▋▎ ▋▋▊▋▎▎▏ + ▏▏▌█▌▍▏▏▋█▍▏ + ▍▏▏▊▏▏▋▋▏ █ + ▏▏ ▏▏▏▏▏▏▊▏ + ▏▏▋▏▏▉▏▏▏ █▊▏ + ▏▏▉▊▊▉▏▏▏▎▋▋▏ + ▏▎▏▌▋▏▍▏▉▋ ▋▏ + ▏▋▏▏▏▏▎▏ ▎ ▌ + ▏▉▉▋▍▏▏▍▊ ▌ + █▏▍▊ ▋▋▋▎ ▌▎ + ▏▍▏▏▍▍▋▉▍▋ + ▍▍▉▍▎▎▎▋ + ▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_16.txt b/codex-rs/tui2/frames/vbars/frame_16.txt new file mode 100644 index 00000000000..1fcc2090a21 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_16.txt @@ -0,0 +1,17 @@ + + ▎▌▉█▉▉▉▏▎ + ▋▍▏▏▉▏▏▍▏▌▍ + ▏▋▋█▍▋▌▍▏▏ █▍ + ▋▋▋ ▌▉▎▍▏▍▏▏▉▉▏ + ▏▏▌▎▋▉█▌▏▏▉▋▏▎▍ + ▏▏▏▉▏█ ▏▍▌ ▏ ▏▋▏ + ▍▍▏ ▋▎▊▏▏▏▏▋▉█▊ ▏ + ▏▋▌ ▏▎▏▏▋▏▏▏█▌ ▎▏ + ▍▍▏▏▏▉▏▋▍▏▏█▉▉ ▋ + ▉█▎▎▊▌▌▍▉▏▋▎▏ ▊▎▏ + ▏▏▏▉██▋▉▍▏▌█▌▋▏▏▎ + ▏▏▉ ▏▎ ▎▋▋▊ ▋ + ▍▏▉ ▏ ▋▊▋▎▊▏ + ▍▏▏█▎▉▏▋▋▉▎▏ + █▋▋▎▌▋▎▎▌▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_17.txt b/codex-rs/tui2/frames/vbars/frame_17.txt new file mode 100644 index 00000000000..1adf01af903 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_17.txt @@ -0,0 +1,17 @@ + + ▊▌▉▉▉▌▌▏▊▎ + ▎▌▉▉▏▏▏▉▌▉▏▊█▍▊ + ▊▉▍▋▎▌▌▎▉▊█▏▏▏▎█▍ + ▋▏▋▎▋▏█▎▎ █▏▌▍▏▏▏▌▍ + ▏▋▎ ▏ ▏█▋▍▌▍▋▋ ▊ + ▎▌▉▊▋▎ ▊▋▋▏ ▋▊▌▋▏▋▋ + ▉ ▌▋▏ ▊▋▋▏▎ ▉█▏▏▋▌▏ + ▊█▌▏ ▋▋▏▏▋▏▊▏▏▌▌▉ + ▊▋▉▋▍▌▌▉▏▍▎▏▍▍▋▍▏▏▏ ▏ + ▏█▎▌▎▎▏▋▉▍▋▏▏▍ ▍▉█▉▍ + ▍▍▍▋███▉▉▉▉▍▎▏▉▋▋▍ ▍█ + ▍▏▏▎▉ ▎▋▏▏▎ ▌ + ▎▍▍ ▉▍▎ ▊█▊▋▍▎▋ + ▊ ▏▉▉▌▍▉▎▎▌▉▋▏█ + ▎▉▌▋▎▊▉▏▉▎▉▉ + ▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_18.txt b/codex-rs/tui2/frames/vbars/frame_18.txt new file mode 100644 index 00000000000..9c46c648214 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_18.txt @@ -0,0 +1,17 @@ + + ▎▌▌▉▌▌▉▌▏▊▎ + ▎▉█▎▌▊▉▉▏▎▎▊█▉▌▌▎ + ▎▋▋▏▉█▉▌█▊█▋▎█▉▋▋▍ ▉ + ▊▏▏▉▋▊▍█▉ █▍▎▎▋▌▏▍▌▍▍▍▊ + ▎▍▋▋ ▋▏ ▎█▉▎▍▋▏▋▏▎▍▌▍▍▎ + ▋▍▏ ▊▊▏▋▎▎▎▋▊▉▊▏█▊▋▍▋▎▋▏ + ▏▏▍▋▋ ▉▋▉▋▏█▎▋█▉▏▌▏▌▊ + █▍▊▏▏ ▍▍█▏▏▌▍ ▏▌▏█ + ▍██ ▊▍▌▌▌▌▌▉▏▏▊▏▉▋▍▊ ▏▉▏▎ + ▎▍▍▏▉▍▉ ▏▏▋▎▎▍▎▏▏▏▎▌▏▎▍ ▊ + ▏▋▏▍▎██▉▉▉▉▉▎ ▍▎▎▋▏▋▉▋▎▊▎ + ▍▍ ▊▍▊ ▎▎▉█▋▉▎█ + ▍▍▉▉▍▍▋ ▎▉▍▎▋▍▊▎ + █▏▋▉▉▉▉▌▌▌▍█▉▎▋▎▊▉ + █▏▏▎▋▎▎▊▋▉█▌▉█ + ▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_19.txt b/codex-rs/tui2/frames/vbars/frame_19.txt new file mode 100644 index 00000000000..572f5ffc324 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_19.txt @@ -0,0 +1,17 @@ + + ▎▎▌▌▉▏▉▌▌▏▋▎ + ▎▉▏▏▏▍▋▌▌▌▎▉▎▊▏▉▊▊ + ▊▉▌▌▋█▏▉▉▎ ▎▉▋▊▎▉▌▍ + ▋▌▏▉▊▍▎ ▎▌▌▉▊▍▏▉▊ + ▏▏▋▎▋▎ ▊▏▍█▋█▏▍ ▏▍▊ + ▏▍▋▉█ ▎▏▉▏▋ ▋▋ █▋▉▏ + ▍▌▏▏█ ▋▉▍▋▏▉▊▎ ▋▋▋▉▏ + ▏▏▏▏ ▏▌█▍▏█▊ ▏ ▏▏▏ + ▏▉▏▍▊▌▌▌▌▌▌▏▏▏▏▉▉▌▉▏▌ ▍ █▏▏ + ▍▌▋▊▏▌▉▋▍▉▉▍▊▎▊ ▍▎ ▋▍▎▍▊▋▋▏▎ + █▍█▋▉▏█████▉▉█ ▍▍▎▋▏▋▋ ▋▋█ + ▍▍▍▉▎▍ ▎▋▋▎▋▋█ + █ ▎▍▏▏▉▊ ▊▌▌▋▋▏▌▎ + ██▋▏▉▋▏▉▎▎▌▌▋▉█ ▌▉▏█ + █▏▋▎▋▊█▎▊▋▍▉▊▍ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_2.txt b/codex-rs/tui2/frames/vbars/frame_2.txt new file mode 100644 index 00000000000..0e0c021f436 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_2.txt @@ -0,0 +1,17 @@ + + ▎▋▎▋▌▉▌▌▌▉▊▎ + ▎▉▊▋▉▌▉▏▏▏▌▏▏▌▎█▏▉▉▎ + ▊▏▉▏▍▉▉▏▉▎▎ ▎█▉▌▉▋▏▏▌▏▊ + ▎▏▋▎▋▉█▊▊▎ ▊▊▍▏▋▏▊ + ▊▍█▋▉▍▏▍▎▍▍▎ ▍▊▏▍▏▊ + █▋▉█▎ ▏▉▍▉▋▍▉ ▍▍▏▉▏ + ▊▏█▉▏ ▍▋▏▌▏▎▏▊ ▋█▏█▏ + ▏█ ▍▎ ▊▏▉▏▏▌▉ ▏█▋▋▏ + ▉██▌▏ ▌▏▍▍▎▏█▋▏▉▉▉▉▉▉▉▉▊▏▎▏▏▉ + ▎▌█▏ ▋▏▏█ ▋▉ ▏▌▍▎▎▎▎▋▋▎▎▏▉▋ ▏ + ▍▍▍ ▉ ▉▍▋▋▏█ ▎█▉▉▉▉▉▉▉█▏▊▉▏▏ + █▍▎▋▋▊ ▎ ▊▉▌▋▊▉ + ▋▍▎▎▏▉▊ ▊▌▎▉ ▎▏█ + ▎▏▍▌▎▎█▉▉▋▌▌▌▌▋▌▉▎▎▏▏▉ + ▎▉▉▉▉▏▏▎▎▎▎▏▌▋▉█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_20.txt b/codex-rs/tui2/frames/vbars/frame_20.txt new file mode 100644 index 00000000000..42c288df929 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_20.txt @@ -0,0 +1,17 @@ + + ▎▎▉▌▏▏▌▉▌▊▎▎ + ▊▌▌█▌▊▎▊▌▌▌▏▏▎▍▉▍▉▊ + ▊▊█▎▉▉█▏▉█ ▎█▍█▍▉▉▏▉▎ + ▎▏█▎▉▏▉ ▎▊▉▏▍▋▉▍▍ + ▎▏ ▊▏▉ ▎▋ ▎▏▏▍▉▍▏▏▍ + ▋▎▊▏█ ▉▉█▌▏█▋ █▍▏▏▍ + ▌▏▌▉▏ ▊▉ ▋▉▉▍ ▊▍▏▋ + ▏▎▍▋▎ ██▌▋▏█▊ ▏▏█▏ + ▏▋▏▏▌▊▌▌▌▌▌▌▌▌▎▊█▌▍▍▍▍▉ ▋ ▏▍ + █▏▍▏▎█ ▋▎▎▎▎▎▎▏▍ ▉▍▍█▏▌▍ ▊▋ ▏▏ + ▍▊▍▍████████▉█ ▍▊▌▏▏▊▏ ▋▍ + ▉ ▍▉▋▊ ▎▋▉▋▏▋ + ▍▎▍▉▊▏▎ ▊▌▉▎▌▉█ + ▍▊▊▋█▏▉▍▌▌▊▋█▏█▎▊▋▉█ + ▎▉▉▏▎ ▌▎▎▎▎▏▉██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_21.txt b/codex-rs/tui2/frames/vbars/frame_21.txt new file mode 100644 index 00000000000..aa5d4f7274c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_21.txt @@ -0,0 +1,17 @@ + + ▎▎▊▌▉▌▌▉▌▉▊▎▎ + ▊▉▉▎▎▎▎▉▏▌▏▉▌▎▎▊▉▉▎ + ▌▉▍▎▉▉▍█▎ ▎▍▏▉▉▎▎▉▊ + ▊▉▏▌██ ▎▊▌▉▊▌▉▊ + ▋▊▋▉▎ ▊▏▋▎▍ ▍▍▎▍▊ + ▋█▋▏ ▊▉▉▎▋▋ ▍▍▍▊ + ▏█▏▎ ▎ ▋▎▊▋█ ▍▌▍█ + ▎▍ ▍▌ █▏▊ ▏ ▏ + ▏ ▏ ▊▉▌▌▌▌▌▉▉▉ ▍▏▊█▏▊ ▌▋ ▏ + ▏ ▏▍ ▏▎ ▎▊▏ ▍█▎▉▏▊ ▋█▋▋ + ▌ █▋ ▎▎███ █▎ █▉▎▊▌ ▋▎ ▊ + █▉ ▋▌▎ ▎▉█ ▋ + ▉▎ ▍▊▎ ▎▋▌▉█ ▊▎ + ▉▌▎ ▉█▉▉▋▏▏▌▋▌▉██ ▊▎ + ▉▉▉▌▉▎▎▎▎▊▎▌▉▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_22.txt b/codex-rs/tui2/frames/vbars/frame_22.txt new file mode 100644 index 00000000000..3b1ce4ecded --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_22.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▉▌▌▉▌▉▊▎ + ▎▉▋▉▏▋▎▋▏▋▍▏▏▌▏▉▋▉▉▎ + ▊▌▉▌▏▍▉▉▎ ▎█▉▍▉▉▉▎▎▉▊ + ▌▉▏▉▉█ ▊▊▏▏▏▎▉▍ + ▍▏▏▋█ ▊▎▋▏▌▏▋▋▌█▏ + ▍▏▉▋▎ ▊▏▋▏▌▍▋▎ ▍▏▍▌▏ + ▏▉▉▍▋ ▏▉▏▍▉▋█ ▏▏▋▏ + ▏▏▌▏▎ ▏▍▏▏▍▍ ▏▍▏█▏ + ▏▏▊█▊ ▉▏▌▌▌▌▌▌▉▉▎▍▉▏▎ ▍▊ ▎▏▌█ + ▍▏▌ ▉▎▍▍▊ ▋▊█▏▋▋▏ ▍▉▌▎█▏▊▊ ▋▍▌ + ▍▍▍▎▏ █████▉▉█▎ ▎▋▉▉▎▋▋▋█▊▎ + ▍▉▍▎▍▊ ▎▉▊█▏▊█ + ██▉▌ ▉▉▎ ▎▋▉▊█▉▎▋ + █▋▉▉▊█▉▏▌▌▉▌▌▋▌▉███▊▉ + █▏█▏▎▌▎▎▎▊▌▎▋▌▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_23.txt b/codex-rs/tui2/frames/vbars/frame_23.txt new file mode 100644 index 00000000000..0b99396129d --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_23.txt @@ -0,0 +1,17 @@ + + ▎▊▉▎▌▉▉▉▉▌▉▎▎ + ▎▌▏▉▏▍▎▊▎▌▌▊▏▎▏▉▏▉▎ + ▊█▉▏█▊▌▉▎ █▏▋▉▏▍▌▏▏▊ + ▋▌▏▉▌▋▎ ▎▌▌▍▏▏▌▍ + ▋▋▏▎▋█ ▊▋▊▊█▉▋▉▍▏▍ + ▊▍▏█▋▎ ▎▉▉▉██▋▉█▏▏▍▏▊ + ▎▏▏█▏ ▋▋▋▋ ▎▏▎ █ ▏█▉ + ▏▋▏ ▏ ▏█▏▊▎▍▊ ▏ ▉▋▏ + ▍█▍ ▏▉▏▌▌▌▌▌▌▌▌▏▎▉▏▎█▍▎ ▏ ▏▏▏ + ▏▍▍▎▏█▌█ ▋▎▍ ▍▏▎▍▊▏ ▍▎▏ + ▋█▏ ▏ ▎███████ █▎▋▌▌▏▎▋▍▋▎ + ▊▏▏▎▏▋ ▊▋▏▍▍▋▎ + ▍ ▏▊█▋▎▎ ▎▋█▌▉▋▉▋ + ▍▌▍▉▌▉▉▍▏▌▌▎▉▉█▊▎▉▉▎ + █▏ ▉▌▍▏▎▎▉▎▎▉▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_24.txt b/codex-rs/tui2/frames/vbars/frame_24.txt new file mode 100644 index 00000000000..5e26d7a27bf --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_24.txt @@ -0,0 +1,17 @@ + + ▎▏▎▌▉▉▉▌▌▊▎ + ▊█▎▋█▏▎▎▏▌▌▉▊▎█▉ + ▊██▌▍▋▉█▏ ▋▎▋▉▏▉▉▊ ▍ + ▊█▋▋▊▋▏▌▊▍▎▏▋▎ ▎▉▍▏▍▎█ + ▊ ▋▋▍▋▊ ▎██▋▍▎▏▎▎▍▏▊█ + ▏▊▍▉▍▎ ▉▊▊▋ ▋▏▌ ▋█▍ + ▏ ▏▏ ▊▋▎▊▋█▋▍▍▏▍▍▏▍ + ▏ ▍▏▏ ▏▊▌▋▍▎▏▏▎▋▋▎▏▏ + ▏▋ ▏▌▌▌▋▉▉▏▏▏▉▉▎▉▊▎▏ ▏ ▏▏ + ▎ ▋ ▉▏▋▊▊▌▌▊▊▏▋▍▍▉▉▏ ▏▉▏ + ▍ █▍▏▍▉██████▎ ▍▊▋▉▍▏▌▋▊▎ + ▍ ▍▊▉▍ ▏ █▋▌▋ + ▍▋▉▍▎▏▋ ▎▌█▎▋▋▎▉ + ▋▊▉▊▍▍█▏▏▋▌▌▌▏▍▎▋▎ + ▉▋▎▉▉▌▍▎▎▌▌▉▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_25.txt b/codex-rs/tui2/frames/vbars/frame_25.txt new file mode 100644 index 00000000000..5009b8b66d2 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_25.txt @@ -0,0 +1,17 @@ + + ▎▉▌▉▌▉▉▉▉▎ + ▊ ▉▊▉▎▎▌▍▏▉▏▏▊ + ▋▏▋▋▊▊█▋▏▉▍▌▍▏▏▏▎ + ▏▉▉▏ ▋ ▌▏ █▍▏▊▉▍▋ + ▊▎▎▍▊▋█▍▌▋▎▏█ ▏▋▋▍▋▉ + ▍▊▏▏▏▎▉▉▋▍▏▋ ▋▏▊▏▋▋▏▊ + ▏▋▏▉▏ ▏▎▎▋▍▎▋▏▍▋▎█▏▌▏ + ▏▏▏▋▏▋█ ▏▎▌▍▋▏▍▎▏▌▏▏▍ + ▍▉▍▏█▏▎▎▌▌▌▌▉▏▏▍▍▏▉▉▏▍ + ▋▊█▏▋▏▊▏▎▏▏▌▉▎▏▍▉▌▋▍▏ + ▍▏▉▋▍▏▎██████▉▋▏▍▏▋▏▎ + ▏▎█▏▌▉▌▍▊▊▉▋▎▋▊▋▋▏▋ + ▍▎▍▏▎▍▎▉▌▋▊▍▋▎▏▏▋ + ▍▋▌▍▍▎▉▌▏▉▉ ▉▏▉ + ▏▊█▉▍▉▊▎▉▏▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_26.txt b/codex-rs/tui2/frames/vbars/frame_26.txt new file mode 100644 index 00000000000..900a51c3b55 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_26.txt @@ -0,0 +1,17 @@ + + ▎▌▉▉▉█▉▊ + ▋▉▏▌▋▍▏▉▌▏▊ + ▋▉██▏▌▍▏▍██▍▊ + ▌▉█▊▏▏▏▎▏▏▎▉▋▍ + ▏▎▊▍▏▋▏█▉▍▋▌▏▍▌ + ▍ █▏▋▋▋█▉▎▍█▍▍▋ + ▏ ▉█▌▏▏▋▏▏▌▌█▉ + ▋▊ ▍▏▏▏▍▍▌▏▏▋▏▏ + ▏ ▋▍▉▌▉▏▉▌▌▋▍▏▏ + ▋▎▏▋▊▏▎▎▊▉▍▍▉▏ + ▏▎█▋▏▉█▍▊▋▎▉▍▏▏ + ▍▎▏▏▋▍ ▋▋█▏▏▎ + ▉ ▉▏▍ ▍▎▋▏▋▊ + ▏█▉▍▍█▉▎▉▉▌ + ▍▋▋▍▉▏▎▍▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_27.txt b/codex-rs/tui2/frames/vbars/frame_27.txt new file mode 100644 index 00000000000..0b2e8c7306f --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_27.txt @@ -0,0 +1,17 @@ + + ▌▉▉▉▉▊ + ▏▌▋▋▏▋▍ + ▌█▎ ▉▏▋█▏ + ▏▏▏▍▏▏▌▏▏ + ▏ ▎▏▎▊█▌ + ▏▎▋▎█▎▏▏▏▎ + ▌ █▏▏▎▉▏▏ + ▏ ▎▏▏▏▌█▏▏ + ▏▋ ▋ ▏▉▏▏▏▏ + █▌ ▋█▎▏▎▉▏▏ + ▏▊ ▎▍▉▉▉▋█ + ▏▏▉▉▏▏▎▋▏ + ▏▌▌▉▌▊▋▏▍ + ▍▎▎▏▍▌▋▋ + █▍█ ▍▍▋ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_28.txt b/codex-rs/tui2/frames/vbars/frame_28.txt new file mode 100644 index 00000000000..01ce82b6d3c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_28.txt @@ -0,0 +1,17 @@ + + ▋▉▉▌ + ▏▉▎▏▎ + ▌ ▌▏▎ + ▏▊▎▉▏ + ▏ ▏▏ + ▏ ▊█▏ + ▍▏▏ ▎▏ + ▏▊██▏ + ▋▍ ▎▏ + ▏▏▋▋▏▏ + ▏ ▎▏ + ▏▌▉▌▏ + ▏▌▉▏▏ + ▉▎▎▋▏ + ▏ ▋▉▏ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_29.txt b/codex-rs/tui2/frames/vbars/frame_29.txt new file mode 100644 index 00000000000..c682a6082c1 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_29.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▊ + ▊▏▉▋ ▉▍ + ▍▊▏▏ ██▏ + ▏▏▏▏▍▍▊▋ + ▍ ▏█▏ ▏ + ▏▊▏▏▏ ▎▎ + ▏▍▍▏▏▏ ▍ + ▏▏▌▏▏ + ▏▏▏▉▏ ▎ + ▎▏▌▏▏ ▎ + ▉▏▏▋▏▍ ▌ + ▏▋▏▏▏ ▎▏ + ▏▏▏▏▎▏▎▏ + ▏▌▏▉▋█▏█ + ▏▏▉ ▋▊ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_3.txt b/codex-rs/tui2/frames/vbars/frame_3.txt new file mode 100644 index 00000000000..6c202bc0c38 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_3.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▉▉▌▌▌▌▊▎ + ▎▉▊▋▍▏▏▉▏█▌▌▏▌▎▍▏▉▉▎ + ▊▌▋▉▌▏▉██▎ ▎█▏ ▉▉▉▋▍▏▊ + ▋▉▋▍▏▌▊▉▎ ▎▌▉ ▏▏▍ + █▋▉▏▋▍▋▏▏▉▍▎ ▍▍▍▏▏▎ + ▋▏▍▍▋ ▋▊▏▍▌▍▍ ▏ ▍▍▏ + ▌▉ ▏ █▉▍▏▎█▏▊ ▋▉▋▏▊ + ▊▊▏▊▏ ▊▊▏█▋▍▏ ▏▎▏▌▏ + ▊██▏ ▌▏▉▉▏▏▉▊▏▌▉▉▎▎▎▉▉▎▏▍█▌▏ + ▊▍▋▍▊ ▋▋▉▋▌▌▏ ▏▉▏▎▎▊ ▌▋▊▌▍▎▏▍▏▎ + █▍▏ ▏ █▏▏▉▊█ █▉▉▉███▉▉▊▋▋▋▊▋ + █▉▏ ▍▉ ▎ ▋▍▉▍▋▋ + ▌▍▋▋▉▍▊ ▊▉▌▋█▊▏█ + ▌▍▉▊▎▉▉▍▌▌▌▌▊▋▌▉▌▎▎▉█ + ▎▉▉▉▎▋▏▎▎▎▎▏▌▋▍█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_30.txt b/codex-rs/tui2/frames/vbars/frame_30.txt new file mode 100644 index 00000000000..a44dbb6ed04 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_30.txt @@ -0,0 +1,17 @@ + + ▎▌█▉▉▉▊ + ▎▋▌▏▏▍▉█▌▍ + ██▍▏▊▍▍▏▉▊▍ + ▏▏▍▏▋▏▎▍▏▍ ▍▊ + ▌▉▏▏▎▍▏▊▏▊ ▏ + ▏▉▏▍▉ ▉▏▏▏▏▋▏ + ▍▋▏▏▏▍▎ ▍▋▍▏ ▌▎ + █▏ █▉▌▏▊▏█▏▊ ▎ + █▏▎█▍▏▌▏▍▋▏▊ ▎ + █▏▏▍▏▎▎▏▏ ▉▊ ▎ + ▍▏▏▌▍▎▉▏▏▏▉▏▏ + ▏▉▋▋▊▋▉▍▏▏█ ▏ + ▍▏▍▏▏▋▉▏▏▊▏▋ + ▊▍▊▉▌▍▋▏▊▋█ + ▍▊▋▏▍▎▎▌█ + ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_31.txt b/codex-rs/tui2/frames/vbars/frame_31.txt new file mode 100644 index 00000000000..70da8799e29 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_31.txt @@ -0,0 +1,17 @@ + + ▎▋▋▉▉▉▉▌▊ + ▎▉▉▌▉▊▎█▉▍█▉▊ + ▊▏▍▋▋▊▌▎▎▋█▏▎▉▍ + ▊▏▋▉▎▎▉▌▎▊▍▍▏▍▏▏▍ + ▍▏▏▏▏▉▊▉▍▎▊█▍▏▋▎▎ + ▌▏▍▎▏▎▏██▍▌▉▊▋▋▏▊█▏ + ▋▏▏ ▏▍▉▏▍▋▌▋▌▉▏▉▏▋▏ + ▍▍▊ ▏▏█▏▍ ▏▍██▏▏▍▋▏ + ▏▍▊▎▏█▍▏▏▏▋▉▏▏▏▎ ▏ + █▏▏▏▋▋▏▏▎▎▎▍▎▏▍▏▏ ▉ + ▍▉▎▋▉▏▊ ▋▉▉▉▎▋▏█▏▎ + ▏▏▏▍▍▍▊ ▎▌▉ ▏ + ▌▏▉▌▎ ▊ ▋ ▏▉▎▋▎ + ▋▏▍▏▋▎▏▎▊▏█▊▏▎ + ██▌▏▎▉▉▋▋▌▉ + ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_32.txt b/codex-rs/tui2/frames/vbars/frame_32.txt new file mode 100644 index 00000000000..ddfb4be3fe2 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_32.txt @@ -0,0 +1,17 @@ + + ▊▉▉▉▉▌▌▏▊▎ + ▌▉▏▉▉▏▍▏▏▉▉▏▏▊ + ▋▍▉▌▍▊▉▍▋▊▎▉▏▉▍█▉▊ + █▋▋▏▎▊▎▌▊ ▊ ▊▎▍▏▉▊ ▊ + ▋▋▏▊▏▉▍█▉▏▌ █▎▊▏▍▋▍▉▍▊ + ▍▋▍▍▊▍▉▏▎▋▏▏▉▎ ▏▉▋ ▉ + ▉▏█▌▊▎█▍▏▏▊▊▏▊▌▎▋▎▋▋▏█▎ + ▏▏▏▊▎ █▎▏▏█▋█▏▌▊▎█▍▏██▎ + ▍▏▏▊▎ █▎▏█▎▎▏▉▉▉▏▏▏▌▏█▌▎ + ███▎▎▏▉▍█▍▎▎▎▎▎▏▏▉▏▌▏▊▎ + ▊▍▉▏▏▋ ▏▉██▉▉▉▍▌▋▋▋▋▋ + ▍▊▏▍▍▍ ▊▍▏▋█▏ + █▉▉▏▏▏▉▎ ▎▋▎▏▋ ▊ + ▍▊▍█▏▉▏▌▌▉▎▎▉█▎▋ + ▏▌▌▎▎▊▉▋▉▎██ + ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_33.txt b/codex-rs/tui2/frames/vbars/frame_33.txt new file mode 100644 index 00000000000..7fa5ac29bca --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_33.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▉▌▉▌▏▋▎ + ▎▉▉▏▏▍▉▌▏▋▏▊▏▉▏▉▋ + ▎▋▍▏▉▉▉▊▌█▎ █▉▋▏▏▉█▊ + ▊▍▋▏▋▎▏▊▎ ▉▏▏▍▎▉ + ▎▋▏▋▎▏▏▋▍▍▍ ▏▉▍▍▍ + ▋▋▊ ▍▎▍▍▎▍▊ ▋▍▋▎▍ + ▏▏▉ ▍▊▉▏▎▏▍ ▏▉▏▏ + ▉▏▊▊ ▊██▍▋▌▊ ▍█▏▉ + ▏▏▏ ▏ ▊▎ ▋▉▊▊▊▌▌▌▉▏▏▉▋▎▏▍▏ + ▍▋▏██▋▋▊▋▎▏▏▎▎▎▎▎▏▏▉▍▍▍▏▏▋ + ▍▌▍▊ ▏▋▏▋ ▋ ████▉▉▉ ▏▏▊▋▍▎ + ▍▉▍▉▊█▊ ▋▋▌▋▌▋ + ▉▍█▉▊▉▌▊ ▎▉▏▊▋▉▍█ + ▍▏█▉▍▉▏▊▌▉▎█▌▊▍▉▏▉ + █▌▌▉▊▎▎▎▍▉▉▌▊█ + ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_34.txt b/codex-rs/tui2/frames/vbars/frame_34.txt new file mode 100644 index 00000000000..a8c447ff18a --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_34.txt @@ -0,0 +1,17 @@ + + ▎▊▌▉▉▉▉▌▏▊▎ + ▎▉▉▉▋▍▌▏█▏▉▉▌▉▉▌▊▌▎ + ▎▋▋▊▏▉█▋▏▉▎ ▎▍▍▍▊█▉▉▊ + ▊▋▎▏▉▎▋▊▎ ▉▏▊▉▍▍ + ▋▋▏▍▊▋▏ ▏█ ▊ ▍▍▉▍▍ + ▊▋▍▏▎▋ ▍▍█▏▍▉▍ ▍▊▍▏▍ + ▏▉▍▏▎ █▍▎▉▏▎▍ ▏▌▏▎ + ▍▏▌█▋ ▏▉▎▏▋█▏ ▋ ▎▍ + ▊█▋▋█ ▋▍▋▋█▎▌▌▉▉▉▉▉▉▏▎▎█ ▏▍▎ + ▏▏▍▊▋▊▊▍█▋▋█▏▏▏▎▏▋▎▎▎▊▋▎▏▏▎▏▍ + ▋▎▍▊ ▏▍▏▉▍ █▉▉▎▎▎▎▍ ▊▋▊▍▏█ + ▋▊▍▌▌▍▎ ▋▌▋▏▋█ + ▍▉▎▏▉▍▌▊ ▎▉▌█▎▋▊▎ + ▊▍▋▏█▏▉▊▌▊▉▌▉▌▉▎▉▉▋█ + █▏▍▌▌▎▎▎▎▎▌▌▉▌▍ + ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_35.txt b/codex-rs/tui2/frames/vbars/frame_35.txt new file mode 100644 index 00000000000..ba905231e1f --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_35.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▌▉▉▉▌▉▊▎ + ▊▉▌▉▋▏▉▉▉█▏▉▏▋█▏▉▌▎ + ▎▋▋▏▌▉▉▏▍▉█▎ ▎█▉▌▉▋█▏▉▊ + ▊▋▋▉▋▊▌▊▎ ▉▉▎▍▏▎ + ▋▋▏▏▋▋▍ █▍▉▊ ▏▍▉▏▊ + ▌▋▌▋▉▎ █▏▎█▋▋▉▎ ▏▍▍▏ + ▍ ▋▋ ▍▍ █▉▎▋ ▏▍▍▍ + ▏▏▏▋ ▋▍▍▌▋█ ▋ █▋ + ▋ ▊▎ ▊▏▏▋▌█▋▏▌▌▎▌▎▌▉▏▏ ▏▏▌▉ + ▏▊▍▌█ ▋▋▌█▉▉▋▏▌ ▍▊▎▎▎▎▋█▏▋█ ▏█ + ▋▎▍▊█▋▍▎▏▋▍▎ ▎█▍▍▍▍▉▍▍▉▋▋▎▊▏ + █▋▍▍▋▏ ▎▋▉ ▊▍ + ▍▌▎▍▌▊▋▊ ▊▌▋▎▉▉█ + █▍▎█▏▉▏▊▋▏▏▉▏▌▋▉█▎▋▌█ + █▍▏▌▌▎▎▋▎▎▉▉▉█▍ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_36.txt b/codex-rs/tui2/frames/vbars/frame_36.txt new file mode 100644 index 00000000000..246ed3d6924 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_36.txt @@ -0,0 +1,17 @@ + + ▎▎▉▌▉▉▉▉▌▉▊▎ + ▎▌██▍▉▋▌▋▉▍▉▌▉▉█▉▉▉▎ + ▊▍█▍▊▉▉▊▉█▎▎ ▎█▉▏▉▉▏▊▉▏▊ + ▎▏█▉▉▎▎▎▎ █▉▏▍█▏ + ▊▋▎▌▍█▏▍▎▍▊▋ ▍▍▋▉ + ▋ ▊▋▎ ▉▎ █▏▋▊ █▍▍▉ + ▏ ▌ ▎ ▍ █▍▍ ▏▍▏▊ + ▏▏▍▏ ▎▍ ▎▋▋ ▍▏ ▏ + ▏██▏ ▋▋ ▊▉██▊▉▉▎▉▉▉▉▉▎ ▏▍▏▏ + ▎▉▏▍ ▊▏▎▎▋▏▋ ▎▏▎▎▎▎▎▎▎▏▏ ▍▋█▎ + █ ▉▏▍ ▉▎▉▋▍ █▉▉▍▍▍▍▍█ ▏▊█▋ + █▊█▍▌▋ ▊▋▏█▋ + ▍▋▏▏▉▏▊ ▊▉▉█▋▊▎ + ▉▊▎█▉▉▌▍▌▏▎▉▏▌█▊█▊▌▉█ + ▎▉▉▋▏▎▊▊▎▊▊▉▉▉▉█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_4.txt b/codex-rs/tui2/frames/vbars/frame_4.txt new file mode 100644 index 00000000000..5dcae750bc0 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_4.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▉▌▌▉▌▉▊▎ + ▎▊█▉▉▏▋ ▏▍▌▋▉▌▏█▋▉▊▎ + ▊▉▌▏▏▏▉▉█▎ ▎▍▏▍▍▉▏▋▍▍▎ + ▋▋▏▊▏█▋▉▊ █▏▉▉▍▉▍ + ▋▏▋▎▏▌▏▍▌▍▏▊ ▍█▍▏▉▍ + ▋▏▏▍▏ █▎▏▏▋▉▍ █ ▍▌▏▊ + █▍▏▌▎ █▉▉▍▉█▏▊ ▊ █▌▏ + ▏▋▍ ▊▏▋▉▍▏▏ ▏▏▏ + ▎▏ ▏▎ ▌▏▋█▍▋▋▉▏▉▉▉▎▎▎▎▊▊ ▏▏▉ + ▏▍▋ ▏ ▊▋▋▋▊▎▏▎▌▍▏▊▋▎▊▋▋▍▌▏▋▋█▏ + ▎▏▌█▍▊▍▍▊▍▋ █▉▉▉▉▉▉▉█▍▊▏▏▏▎ + ▊▏▍█▏▎▎ ▊▍▌▋▉▉█ + ▍▍▊▋▍ ▊▎ ▎▋▍▊▉▌▏▋ + ▏▏▏▌▎▉▉▍▌▌▌▌▊▉▌▉▉▍▏▉▎ + ▉▍▍▏▋▏▎▎▎▎▎▌▊▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_5.txt b/codex-rs/tui2/frames/vbars/frame_5.txt new file mode 100644 index 00000000000..cab16091cb9 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_5.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▌▉▌▌▌▉▊▎ + ▎▉█▉▋▉▋▏▏▉▏▌▉▌▎▏█▉▊ + ▉▎▋▍▉▏▉█▎ █▍▌▏▉▍ ▊▉▊ + ▊▏▋▏▋▏▋▌▊ ▊▍▏▏▏▏▎ + ▊▍▋▉▋▍▎▉▏▎▍▊ ▎ ▍▏▍▏▎ + ▏▏▏▏▋ ▍▎▏▍▌▋▏▎ ▉▋▏▏ + ▏▋▏ ▉ ▎▍▎▏▍█▏▊ ██▏▉▏▊ + ▏▋▏▎█ ▊▎▌▏▋▎▏ ▉▏▎▏ + ▏▊▏▍▉ ▋▎▏█▊▏▉▌▎▏▉▏▎▎▎▉▊▎█▏▋▏ + ▎▍▏▍▊▊▋▉▉█ ▏█ ▋▋▍▌▍▊▌▋▊▋▏▋▉▏▎ + ▍▊▏▏▊▏▊▍▍▋▉▎ █▉▉█████▊▏▌▏▍▋ + ▍▍▉▋▍▍ ▎ ▋▎▋▉▊▍ + ▍▋▉▍▏▌▉▎ ▎▌▉▊▉█▌▉ + ▍▏▉▏▊▉▉▍▌▏▌▎▊▉▌▉▍▏▌▉ + ▍▊▉▎▉█▎▎▎▊▎▊▉▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_6.txt b/codex-rs/tui2/frames/vbars/frame_6.txt new file mode 100644 index 00000000000..e41e013ab0f --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_6.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▌▉▌▌▉▉▎▎ + ▊▍▌▋▉▏▌▉▉▏▉▉▌▎▊▉▉▎ + ▊▊▊▏▏▏▉█ ▎█▏▋▌▉▉▉▋▍▎ + ▋▊▏▉▏▉▎▌▊ ▍▉▏▏▏▏▊ + █▌▏▏▏▉▍▍▉▉▍ ▋▍▍▏▍▊ + ▌▊▋▏▋▎▍▉▌▏▍▎▏▊ ▋▌▍▋▏ + ▎ ▉▏▏ ▍▍▏▋▍▍ ▍▉▏▎▏ + ▏ ▏▏▏ ▎▏▌▏▋▏▏ ▏▉▍▏▏ + ▎▋▏▊▏ ▋█▏▏▉▌▋▉▎▏▉▏▎▎▉▉▎▋▍▋▏ + ▏▏▍▊▋▎▍▎▌▉ ▏▋▌▉▊▏▏▊▌▌▌▍▏▊▏▍ + ▋ ▏ ▋▏▏▏▌▋█ █▉████▉█▎▍▍▋▏ + ▊ ▌ ▋▊▎ ▊▋▎▋▋▏▎ + ▋▎▍▎▉▉▊ ▎▉▍▎▋▉▊▉ + ▎█▏▉▊█▉ ▌▏▎▎▊▉▉▉ ▋▌█ + ▍▊▌▉▉▊▎▎▎▎▎▊▉▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_7.txt b/codex-rs/tui2/frames/vbars/frame_7.txt new file mode 100644 index 00000000000..7a88d5ef148 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_7.txt @@ -0,0 +1,17 @@ + + ▎▋▏▌▉▉▌▌▉▊▎ + ▊▉▎▉▍▏▉▉▉▌▌▍▌ ▎▉▊ + ▉ ▊▏▏▏▉▎ ▎▍▏▎ ▉▋▎▉▊ + ▍▍▏▏▏▏▌▉ ▍▏▍▉▏▋▊ + ▎█▏▉▏▏▍▏▋▍▊ ▋ ▉▏▏▍ + ▏ ▌▏▏█ ▎█▍▌▏▍ ▊▊▏▏▏ + ▎▍█▏ ▍▎▏▍▏▍▏ ▉▉▍▏▏ + ▎█▋▏▏ ▌▉▋▏▉▏▎ █ ▏▏▏ + ▋█▏▉ ▊█▎▋▏▍▋▏▏▌▌▎▏▏▌▌▉▍▏▏ + ▏▊█▏▏▌▋▏▏▋█▏▋▍▏▏▍▍▊▌▊▌▍▏▍▏ + ▍ ▌▍▋▉▏▉▎▋ ▉▉▉▉▉▉██▎▋▏▏ + █▎ ▋▎▋▎▎ ▊█▊▋▌▋█ + █▌▊▉▍▍▍▎ ▊▍▏▊▉▋▉█ + ▍▎█▍▏▉▍▌▏▎▎▎▉▉▏▉▉▋ + █▏▉█▏▋▏▎▎▊▎▌▉▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_8.txt b/codex-rs/tui2/frames/vbars/frame_8.txt new file mode 100644 index 00000000000..bbf2016faba --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_8.txt @@ -0,0 +1,17 @@ + + ▎▎▉▎▌▉▉▌▉▊▎ + ▊█▉▋▏▏▏▉▌▌ ▏ █▉ + ▎▏▌▊▏▋▋▉ ▊█▏▋▏▉▍▉▍▊ + ▎▉▍▉▋▏▊▉▏ █ ▌▍▏▏▍ + █ ▋▏▋▊▍▏▋▍ ▋█▍▏▏▊ + ▏█▊▏▍▍▍▎▍▏▎▉ ▍▉▏▏▏ + ▏▉▏▏▏ █▎▌▍▏▎▏ ▏▍▏▋▏▊ + ▍▊▏▏▋ ▏▉▍▋▏▊▉▋ ▋▎▋▏▏ + ▍ ▍▏▏▎▍ ▋▉▌▏▏▋▉▊▉▊▊▍▏▏▏ + ▍▉▋▌▎▏█▊▏▊▏▏▌▏▌▎▎▏▏▌█ + ▋▋▍▍▏▍▏▏▏▋█▍▉▍▉█▍▉█▋▍▏ + █▊█▏▍▏▎▎ ▋▊▋▋▋█ + █▍▎▉▍▍▍▎ ▎▋▍▎▏▉▋▉ + ▋▋█▏▊▉ ▌▌▉▉▌▋▌▊▏▎ + ▏▏▎▉▉▍▏▎▊▉▋▌▎ + ▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_9.txt b/codex-rs/tui2/frames/vbars/frame_9.txt new file mode 100644 index 00000000000..4e36e6e126f --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_9.txt @@ -0,0 +1,17 @@ + + ▋▌▍▉▋▉▉▌▊ + ▋▉▎▋▏▏▉█▌ ▎▊▍▎ + ▋ ▊▏▏▋▉▍▍▌▋ ▍▎▏▊ + █ ▏▏▋▌▌▎ ▎▍▊▏▍▍▏▉▊ + ▋▉█▌▍▉█▏▉▊ ▉▋█▉▋▉▏▏ + ▏ ▏▏▏█▍▏▍▌▎▎ ▏█▏▉▏▏▏ + ▎ ▏▉▍▏▉▉▏▍▍▊█▋▊▋ ▎▏▏ + ▏ ▏▋▎▊▍▏▏▏▎▋▌▍▎▏ ▏▏ + █▊▏█ ▋▏█▏▏▏▏▉▏▏▊▏▏▎ + ▏▎▏▏▏▎▊▏▍▏▎▏▏▏▏▎▎▏▏▏ + █ ▎▍▋▍▍▏▋█▉▉▉▉▏▏█▋▍▏ + ▍▏▏▏▍▊▎ ▊▋ ▍▋▏▎ + ▍▉▍▏▍▍ ▋▌▎▌▏▋▉ + ▍▊█▍▎▏▉▋▉▌▌▌▉▏▎ + █▊▎ ▉▍▍▏▌▉▋█ + \ No newline at end of file diff --git a/codex-rs/tui2/prompt_for_init_command.md b/codex-rs/tui2/prompt_for_init_command.md new file mode 100644 index 00000000000..b8fd3886b3e --- /dev/null +++ b/codex-rs/tui2/prompt_for_init_command.md @@ -0,0 +1,40 @@ +Generate a file named AGENTS.md that serves as a contributor guide for this repository. +Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. +Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project. + +Document Requirements + +- Title the document "Repository Guidelines". +- Use Markdown headings (#, ##, etc.) for structure. +- Keep the document concise. 200-400 words is optimal. +- Keep explanations short, direct, and specific to this repository. +- Provide examples where helpful (commands, directory paths, naming patterns). +- Maintain a professional, instructional tone. + +Recommended Sections + +Project Structure & Module Organization + +- Outline the project structure, including where the source code, tests, and assets are located. + +Build, Test, and Development Commands + +- List key commands for building, testing, and running locally (e.g., npm test, make build). +- Briefly explain what each command does. + +Coding Style & Naming Conventions + +- Specify indentation rules, language-specific style preferences, and naming patterns. +- Include any formatting or linting tools used. + +Testing Guidelines + +- Identify testing frameworks and coverage requirements. +- State test naming conventions and how to run tests. + +Commit & Pull Request Guidelines + +- Summarize commit message conventions found in the project’s Git history. +- Outline pull request requirements (descriptions, linked issues, screenshots, etc.). + +(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions. diff --git a/codex-rs/tui2/src/additional_dirs.rs b/codex-rs/tui2/src/additional_dirs.rs new file mode 100644 index 00000000000..cc43f3294b4 --- /dev/null +++ b/codex-rs/tui2/src/additional_dirs.rs @@ -0,0 +1,71 @@ +use codex_core::protocol::SandboxPolicy; +use std::path::PathBuf; + +/// Returns a warning describing why `--add-dir` entries will be ignored for the +/// resolved sandbox policy. The caller is responsible for presenting the +/// warning to the user (for example, printing to stderr). +pub fn add_dir_warning_message( + additional_dirs: &[PathBuf], + sandbox_policy: &SandboxPolicy, +) -> Option { + if additional_dirs.is_empty() { + return None; + } + + match sandbox_policy { + SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess => None, + SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), + } +} + +fn format_warning(additional_dirs: &[PathBuf]) -> String { + let joined_paths = additional_dirs + .iter() + .map(|path| path.to_string_lossy()) + .collect::>() + .join(", "); + format!( + "Ignoring --add-dir ({joined_paths}) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." + ) +} + +#[cfg(test)] +mod tests { + use super::add_dir_warning_message; + use codex_core::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn returns_none_for_workspace_write() { + let sandbox = SandboxPolicy::new_workspace_write_policy(); + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + + #[test] + fn returns_none_for_danger_full_access() { + let sandbox = SandboxPolicy::DangerFullAccess; + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + + #[test] + fn warns_for_read_only() { + let sandbox = SandboxPolicy::ReadOnly; + let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")]; + let message = add_dir_warning_message(&dirs, &sandbox) + .expect("expected warning for read-only sandbox"); + assert_eq!( + message, + "Ignoring --add-dir (relative, /abs) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." + ); + } + + #[test] + fn returns_none_when_no_additional_dirs() { + let sandbox = SandboxPolicy::ReadOnly; + let dirs: Vec = Vec::new(); + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } +} diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs new file mode 100644 index 00000000000..4d4970b5722 --- /dev/null +++ b/codex-rs/tui2/src/app.rs @@ -0,0 +1,1510 @@ +use crate::app_backtrack::BacktrackState; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ApprovalRequest; +use crate::chatwidget::ChatWidget; +use crate::diff_render::DiffSummary; +use crate::exec_command::strip_bash_lc_and_escape; +use crate::file_search::FileSearchManager; +use crate::history_cell::HistoryCell; +use crate::model_migration::ModelMigrationOutcome; +use crate::model_migration::migration_copy_for_config; +use crate::model_migration::run_model_migration_prompt; +use crate::pager_overlay::Overlay; +use crate::render::highlight::highlight_bash_to_lines; +use crate::render::renderable::Renderable; +use crate::resume_picker::ResumeSelection; +use crate::skill_error_prompt::SkillErrorPromptOutcome; +use crate::skill_error_prompt::run_skill_error_prompt; +use crate::tui; +use crate::tui::TuiEvent; +use crate::update_action::UpdateAction; +use codex_ansi_escape::ansi_escape_line; +use codex_app_server_protocol::AuthMode; +use codex_core::AuthManager; +use codex_core::ConversationManager; +use codex_core::config::Config; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::features::Feature; +use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::protocol::EventMsg; +use codex_core::protocol::FinalOutput; +use codex_core::protocol::Op; +use codex_core::protocol::SessionSource; +use codex_core::protocol::TokenUsage; +use codex_core::skills::load_skills; +use codex_core::skills::model::SkillMetadata; +use codex_protocol::ConversationId; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelUpgrade; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; +use tokio::select; +use tokio::sync::mpsc::unbounded_channel; + +#[cfg(not(debug_assertions))] +use crate::history_cell::UpdateAvailableHistoryCell; + +const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; +const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; + +#[derive(Debug, Clone)] +pub struct AppExitInfo { + pub token_usage: TokenUsage, + pub conversation_id: Option, + pub update_action: Option, +} + +impl From for codex_tui::AppExitInfo { + fn from(info: AppExitInfo) -> Self { + codex_tui::AppExitInfo { + token_usage: info.token_usage, + conversation_id: info.conversation_id, + update_action: info.update_action.map(Into::into), + } + } +} + +fn session_summary( + token_usage: TokenUsage, + conversation_id: Option, +) -> Option { + if token_usage.is_zero() { + return None; + } + + let usage_line = FinalOutput::from(token_usage).to_string(); + let resume_command = + conversation_id.map(|conversation_id| format!("codex resume {conversation_id}")); + Some(SessionSummary { + usage_line, + resume_command, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SessionSummary { + usage_line: String, + resume_command: Option, +} + +fn should_show_model_migration_prompt( + current_model: &str, + target_model: &str, + hide_prompt_flag: Option, + available_models: Vec, +) -> bool { + if target_model == current_model || hide_prompt_flag.unwrap_or(false) { + return false; + } + + available_models + .iter() + .filter(|preset| preset.upgrade.is_some()) + .any(|preset| preset.model == current_model) +} + +fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> Option { + match migration_config_key { + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => { + config.notices.hide_gpt_5_1_codex_max_migration_prompt + } + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => config.notices.hide_gpt5_1_migration_prompt, + _ => None, + } +} + +async fn handle_model_migration_prompt_if_needed( + tui: &mut tui::Tui, + config: &mut Config, + model: &str, + app_event_tx: &AppEventSender, + auth_mode: Option, + models_manager: Arc, +) -> Option { + let available_models = models_manager.list_models(config).await; + let upgrade = available_models + .iter() + .find(|preset| preset.model == model) + .and_then(|preset| preset.upgrade.as_ref()); + + if let Some(ModelUpgrade { + id: target_model, + reasoning_effort_mapping, + migration_config_key, + }) = upgrade + { + if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key.as_str()) { + return None; + } + + let target_model = target_model.to_string(); + let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str()); + if !should_show_model_migration_prompt( + model, + &target_model, + hide_prompt_flag, + available_models.clone(), + ) { + return None; + } + + let prompt_copy = migration_copy_for_config(migration_config_key.as_str()); + match run_model_migration_prompt(tui, prompt_copy).await { + ModelMigrationOutcome::Accepted => { + app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { + migration_config: migration_config_key.to_string(), + }); + config.model = Some(target_model.clone()); + + let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping + && let Some(reasoning_effort) = config.model_reasoning_effort + { + reasoning_effort_mapping + .get(&reasoning_effort) + .cloned() + .or(config.model_reasoning_effort) + } else { + config.model_reasoning_effort + }; + + config.model_reasoning_effort = mapped_effort; + + app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); + app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); + app_event_tx.send(AppEvent::PersistModelSelection { + model: target_model.clone(), + effort: mapped_effort, + }); + } + ModelMigrationOutcome::Rejected => { + app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { + migration_config: migration_config_key.to_string(), + }); + } + ModelMigrationOutcome::Exit => { + return Some(AppExitInfo { + token_usage: TokenUsage::default(), + conversation_id: None, + update_action: None, + }); + } + } + } + + None +} + +pub(crate) struct App { + pub(crate) server: Arc, + pub(crate) app_event_tx: AppEventSender, + pub(crate) chat_widget: ChatWidget, + pub(crate) auth_manager: Arc, + /// Config is stored here so we can recreate ChatWidgets as needed. + pub(crate) config: Config, + pub(crate) current_model: String, + pub(crate) active_profile: Option, + + pub(crate) file_search: FileSearchManager, + + pub(crate) transcript_cells: Vec>, + + // Pager overlay state (Transcript or Static like Diff) + pub(crate) overlay: Option, + pub(crate) deferred_history_lines: Vec>, + has_emitted_history_lines: bool, + + pub(crate) enhanced_keys_supported: bool, + + /// Controls the animation thread that sends CommitTick events. + pub(crate) commit_anim_running: Arc, + + // Esc-backtracking state grouped + pub(crate) backtrack: crate::app_backtrack::BacktrackState, + pub(crate) feedback: codex_feedback::CodexFeedback, + /// Set when the user confirms an update; propagated on exit. + pub(crate) pending_update_action: Option, + + /// Ignore the next ShutdownComplete event when we're intentionally + /// stopping a conversation (e.g., before starting a new one). + suppress_shutdown_complete: bool, + + // One-shot suppression of the next world-writable scan after user confirmation. + skip_world_writable_scan_once: bool, + + pub(crate) skills: Option>, +} + +impl App { + async fn shutdown_current_conversation(&mut self) { + if let Some(conversation_id) = self.chat_widget.conversation_id() { + self.suppress_shutdown_complete = true; + self.chat_widget.submit_op(Op::Shutdown); + self.server.remove_conversation(&conversation_id).await; + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn run( + tui: &mut tui::Tui, + auth_manager: Arc, + mut config: Config, + active_profile: Option, + initial_prompt: Option, + initial_images: Vec, + resume_selection: ResumeSelection, + feedback: codex_feedback::CodexFeedback, + is_first_run: bool, + ) -> Result { + use tokio_stream::StreamExt; + let (app_event_tx, mut app_event_rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(app_event_tx); + + let auth_mode = auth_manager.auth().map(|auth| auth.mode); + let conversation_manager = Arc::new(ConversationManager::new( + auth_manager.clone(), + SessionSource::Cli, + )); + let mut model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; + let exit_info = handle_model_migration_prompt_if_needed( + tui, + &mut config, + model.as_str(), + &app_event_tx, + auth_mode, + conversation_manager.get_models_manager(), + ) + .await; + if let Some(exit_info) = exit_info { + return Ok(exit_info); + } + if let Some(updated_model) = config.model.clone() { + model = updated_model; + } + + let skills_outcome = load_skills(&config); + if !skills_outcome.errors.is_empty() { + match run_skill_error_prompt(tui, &skills_outcome.errors).await { + SkillErrorPromptOutcome::Exit => { + return Ok(AppExitInfo { + token_usage: TokenUsage::default(), + conversation_id: None, + update_action: None, + }); + } + SkillErrorPromptOutcome::Continue => {} + } + } + + let skills = if config.features.enabled(Feature::Skills) { + Some(skills_outcome.skills.clone()) + } else { + None + }; + + let enhanced_keys_supported = tui.enhanced_keys_supported(); + let model_family = conversation_manager + .get_models_manager() + .construct_model_family(model.as_str(), &config) + .await; + let mut chat_widget = match resume_selection { + ResumeSelection::StartFresh | ResumeSelection::Exit => { + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), + feedback: feedback.clone(), + skills: skills.clone(), + is_first_run, + model_family: model_family.clone(), + }; + ChatWidget::new(init, conversation_manager.clone()) + } + ResumeSelection::Resume(path) => { + let resumed = conversation_manager + .resume_conversation_from_rollout( + config.clone(), + path.clone(), + auth_manager.clone(), + ) + .await + .wrap_err_with(|| { + format!("Failed to resume session from {}", path.display()) + })?; + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), + feedback: feedback.clone(), + skills: skills.clone(), + is_first_run, + model_family: model_family.clone(), + }; + ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ) + } + }; + + chat_widget.maybe_prompt_windows_sandbox_enable(); + + let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + #[cfg(not(debug_assertions))] + let upgrade_version = crate::updates::get_upgrade_version(&config); + + let mut app = Self { + server: conversation_manager.clone(), + app_event_tx, + chat_widget, + auth_manager: auth_manager.clone(), + config, + current_model: model.clone(), + active_profile, + file_search, + enhanced_keys_supported, + transcript_cells: Vec::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + feedback: feedback.clone(), + pending_update_action: None, + suppress_shutdown_complete: false, + skip_world_writable_scan_once: false, + skills, + }; + + // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. + #[cfg(target_os = "windows")] + { + let should_check = codex_core::get_platform_sandbox().is_some() + && matches!( + app.config.sandbox_policy, + codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_core::protocol::SandboxPolicy::ReadOnly + ) + && !app + .config + .notices + .hide_world_writable_warning + .unwrap_or(false); + if should_check { + let cwd = app.config.cwd.clone(); + let env_map: std::collections::HashMap = std::env::vars().collect(); + let tx = app.app_event_tx.clone(); + let logs_base_dir = app.config.codex_home.clone(); + let sandbox_policy = app.config.sandbox_policy.clone(); + Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); + } + } + + #[cfg(not(debug_assertions))] + if let Some(latest_version) = upgrade_version { + app.handle_event( + tui, + AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( + latest_version, + crate::update_action::get_update_action(), + ))), + ) + .await?; + } + + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); + + tui.frame_requester().schedule_frame(); + + while select! { + Some(event) = app_event_rx.recv() => { + app.handle_event(tui, event).await? + } + Some(event) = tui_events.next() => { + app.handle_tui_event(tui, event).await? + } + } {} + tui.terminal.clear()?; + Ok(AppExitInfo { + token_usage: app.token_usage(), + conversation_id: app.chat_widget.conversation_id(), + update_action: app.pending_update_action, + }) + } + + pub(crate) async fn handle_tui_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + if self.overlay.is_some() { + let _ = self.handle_backtrack_overlay_event(tui, event).await?; + } else { + match event { + TuiEvent::Key(key_event) => { + self.handle_key_event(tui, key_event).await; + } + TuiEvent::Paste(pasted) => { + // Many terminals convert newlines to \r when pasting (e.g., iTerm2), + // but tui-textarea expects \n. Normalize CR to LF. + // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 + // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 + let pasted = pasted.replace("\r", "\n"); + self.chat_widget.handle_paste(pasted); + } + TuiEvent::Draw => { + self.chat_widget.maybe_post_pending_notification(tui); + if self + .chat_widget + .handle_paste_burst_tick(tui.frame_requester()) + { + return Ok(true); + } + tui.draw( + self.chat_widget.desired_height(tui.terminal.size()?.width), + |frame| { + self.chat_widget.render(frame.area(), frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + }, + )?; + } + } + } + Ok(true) + } + + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + let model_family = self + .server + .get_models_manager() + .construct_model_family(self.current_model.as_str(), &self.config) + .await; + match event { + AppEvent::NewSession => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + self.shutdown_current_conversation().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + skills: self.skills.clone(), + is_first_run: false, + model_family: model_family.clone(), + }; + self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.current_model = model_family.get_model_slug().to_string(); + if let Some(summary) = summary { + let mut lines: Vec> = vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec!["To continue this session, run ".into(), command.cyan()]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + tui.frame_requester().schedule_frame(); + } + AppEvent::OpenResumePicker => { + match crate::resume_picker::run_resume_picker( + tui, + &self.config.codex_home, + &self.config.model_provider_id, + false, + ) + .await? + { + ResumeSelection::Resume(path) => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + match self + .server + .resume_conversation_from_rollout( + self.config.clone(), + path.clone(), + self.auth_manager.clone(), + ) + .await + { + Ok(resumed) => { + self.shutdown_current_conversation().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + skills: self.skills.clone(), + is_first_run: false, + model_family: model_family.clone(), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ); + self.current_model = model_family.get_model_slug().to_string(); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to resume session from {}: {err}", + path.display() + )); + } + } + } + ResumeSelection::Exit | ResumeSelection::StartFresh => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } + AppEvent::InsertHistoryCell(cell) => { + let cell: Arc = cell.into(); + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(cell.clone()); + tui.frame_requester().schedule_frame(); + } + 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 + // part of an ongoing stream. Streaming continuations should not + // accrue extra blank lines between chunks. + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + } + AppEvent::StartCommitAnimation => { + if self + .commit_anim_running + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok() + { + let tx = self.app_event_tx.clone(); + let running = self.commit_anim_running.clone(); + thread::spawn(move || { + while running.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(50)); + tx.send(AppEvent::CommitTick); + } + }); + } + } + AppEvent::StopCommitAnimation => { + self.commit_anim_running.store(false, Ordering::Release); + } + AppEvent::CommitTick => { + self.chat_widget.on_commit_tick(); + } + AppEvent::CodexEvent(event) => { + if self.suppress_shutdown_complete + && matches!(event.msg, EventMsg::ShutdownComplete) + { + self.suppress_shutdown_complete = false; + return Ok(true); + } + self.chat_widget.handle_codex_event(event); + } + AppEvent::ConversationHistory(ev) => { + self.on_conversation_history_for_backtrack(tui, ev).await?; + } + AppEvent::ExitRequest => { + return Ok(false); + } + AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), + AppEvent::DiffResult(text) => { + // Clear the in-progress state in the bottom pane + self.chat_widget.on_diff_complete(); + // Enter alternate screen using TUI helper and build pager lines + let _ = tui.enter_alt_screen(); + let pager_lines: Vec> = if text.trim().is_empty() { + vec!["No changes detected.".italic().into()] + } else { + text.lines().map(ansi_escape_line).collect() + }; + self.overlay = Some(Overlay::new_static_with_lines( + pager_lines, + "D I F F".to_string(), + )); + tui.frame_requester().schedule_frame(); + } + AppEvent::StartFileSearch(query) => { + if !query.is_empty() { + self.file_search.on_user_query(query); + } + } + AppEvent::FileSearchResult { query, matches } => { + self.chat_widget.apply_file_search_result(query, matches); + } + AppEvent::RateLimitSnapshotFetched(snapshot) => { + self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + } + AppEvent::UpdateReasoningEffort(effort) => { + self.on_update_reasoning_effort(effort); + } + AppEvent::UpdateModel(model) => { + let model_family = self + .server + .get_models_manager() + .construct_model_family(&model, &self.config) + .await; + self.chat_widget.set_model(&model, model_family); + self.current_model = model; + } + AppEvent::OpenReasoningPopup { model } => { + self.chat_widget.open_reasoning_popup(model); + } + AppEvent::OpenAllModelsPopup { models } => { + self.chat_widget.open_all_models_popup(models); + } + AppEvent::OpenFullAccessConfirmation { preset } => { + self.chat_widget.open_full_access_confirmation(preset); + } + AppEvent::OpenWorldWritableWarningConfirmation { + preset, + sample_paths, + extra_count, + failed_scan, + } => { + self.chat_widget.open_world_writable_warning_confirmation( + preset, + sample_paths, + extra_count, + failed_scan, + ); + } + AppEvent::OpenFeedbackNote { + category, + include_logs, + } => { + self.chat_widget.open_feedback_note(category, include_logs); + } + AppEvent::OpenFeedbackConsent { category } => { + self.chat_widget.open_feedback_consent(category); + } + AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { + self.chat_widget.open_windows_sandbox_enable_prompt(preset); + } + AppEvent::EnableWindowsSandboxForAgentMode { preset } => { + #[cfg(target_os = "windows")] + { + let profile = self.active_profile.as_deref(); + let feature_key = Feature::WindowsSandbox.key(); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(profile) + .set_feature_enabled(feature_key, true) + .apply() + .await + { + Ok(()) => { + self.config.set_windows_sandbox_globally(true); + self.chat_widget.clear_forced_auto_mode_downgrade(); + if let Some((sample_paths, extra_count, failed_scan)) = + self.chat_widget.world_writable_warning_details() + { + self.app_event_tx.send( + AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset.clone()), + sample_paths, + extra_count, + failed_scan, + }, + ); + } else { + self.app_event_tx.send(AppEvent::CodexOp( + Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(preset.approval), + sandbox_policy: Some(preset.sandbox.clone()), + model: None, + effort: None, + summary: None, + }, + )); + self.app_event_tx + .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); + self.app_event_tx + .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); + self.chat_widget.add_info_message( + "Enabled experimental Windows sandbox.".to_string(), + None, + ); + } + } + Err(err) => { + tracing::error!( + error = %err, + "failed to enable Windows sandbox feature" + ); + self.chat_widget.add_error_message(format!( + "Failed to enable the Windows sandbox feature: {err}" + )); + } + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = preset; + } + } + AppEvent::PersistModelSelection { model, effort } => { + let profile = self.active_profile.as_deref(); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(profile) + .set_model(Some(model.as_str()), effort) + .apply() + .await + { + Ok(()) => { + let mut message = format!("Model changed to {model}"); + if let Some(label) = Self::reasoning_label_for(&model, effort) { + message.push(' '); + message.push_str(label); + } + if let Some(profile) = profile { + message.push_str(" for "); + message.push_str(profile); + message.push_str(" profile"); + } + self.chat_widget.add_info_message(message, None); + } + Err(err) => { + tracing::error!( + error = %err, + "failed to persist model selection" + ); + if let Some(profile) = profile { + self.chat_widget.add_error_message(format!( + "Failed to save model for profile `{profile}`: {err}" + )); + } else { + self.chat_widget + .add_error_message(format!("Failed to save default model: {err}")); + } + } + } + } + AppEvent::UpdateAskForApprovalPolicy(policy) => { + self.chat_widget.set_approval_policy(policy); + } + AppEvent::UpdateSandboxPolicy(policy) => { + #[cfg(target_os = "windows")] + let policy_is_workspace_write_or_ro = matches!( + policy, + codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_core::protocol::SandboxPolicy::ReadOnly + ); + + self.config.sandbox_policy = policy.clone(); + #[cfg(target_os = "windows")] + if !matches!(policy, codex_core::protocol::SandboxPolicy::ReadOnly) + || codex_core::get_platform_sandbox().is_some() + { + self.config.forced_auto_mode_downgraded_on_windows = false; + } + self.chat_widget.set_sandbox_policy(policy); + + // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. + #[cfg(target_os = "windows")] + { + // One-shot suppression if the user just confirmed continue. + if self.skip_world_writable_scan_once { + self.skip_world_writable_scan_once = false; + return Ok(true); + } + + let should_check = codex_core::get_platform_sandbox().is_some() + && policy_is_workspace_write_or_ro + && !self.chat_widget.world_writable_warning_hidden(); + if should_check { + let cwd = self.config.cwd.clone(); + let env_map: std::collections::HashMap = + std::env::vars().collect(); + let tx = self.app_event_tx.clone(); + let logs_base_dir = self.config.codex_home.clone(); + let sandbox_policy = self.config.sandbox_policy.clone(); + Self::spawn_world_writable_scan( + cwd, + env_map, + logs_base_dir, + sandbox_policy, + tx, + ); + } + } + } + AppEvent::SkipNextWorldWritableScan => { + self.skip_world_writable_scan_once = true; + } + AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { + self.chat_widget.set_full_access_warning_acknowledged(ack); + } + AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => { + self.chat_widget + .set_world_writable_warning_acknowledged(ack); + } + AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { + self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); + } + AppEvent::PersistFullAccessWarningAcknowledged => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_full_access_warning(true) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist full access warning acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to save full access confirmation preference: {err}" + )); + } + } + AppEvent::PersistWorldWritableWarningAcknowledged => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_world_writable_warning(true) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist world-writable warning acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to save Agent mode warning preference: {err}" + )); + } + } + AppEvent::PersistRateLimitSwitchPromptHidden => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_rate_limit_model_nudge(true) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist rate limit switch prompt preference" + ); + self.chat_widget.add_error_message(format!( + "Failed to save rate limit reminder preference: {err}" + )); + } + } + AppEvent::PersistModelMigrationPromptAcknowledged { migration_config } => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_model_migration_prompt(&migration_config, true) + .apply() + .await + { + tracing::error!(error = %err, "failed to persist model migration prompt acknowledgement"); + self.chat_widget.add_error_message(format!( + "Failed to save model migration prompt preference: {err}" + )); + } + } + AppEvent::OpenApprovalsPopup => { + self.chat_widget.open_approvals_popup(); + } + 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::FullScreenApprovalRequest(request) => match request { + ApprovalRequest::ApplyPatch { cwd, changes, .. } => { + let _ = tui.enter_alt_screen(); + let diff_summary = DiffSummary::new(changes, cwd); + self.overlay = Some(Overlay::new_static_with_renderables( + vec![diff_summary.into()], + "P A T C H".to_string(), + )); + } + ApprovalRequest::Exec { command, .. } => { + let _ = tui.enter_alt_screen(); + let full_cmd = strip_bash_lc_and_escape(&command); + let full_cmd_lines = highlight_bash_to_lines(&full_cmd); + self.overlay = Some(Overlay::new_static_with_lines( + full_cmd_lines, + "E X E C".to_string(), + )); + } + ApprovalRequest::McpElicitation { + server_name, + message, + .. + } => { + let _ = tui.enter_alt_screen(); + let paragraph = Paragraph::new(vec![ + Line::from(vec!["Server: ".into(), server_name.bold()]), + Line::from(""), + Line::from(message), + ]) + .wrap(Wrap { trim: false }); + self.overlay = Some(Overlay::new_static_with_renderables( + vec![Box::new(paragraph)], + "E L I C I T A T I O N".to_string(), + )); + } + }, + } + Ok(true) + } + + fn reasoning_label(reasoning_effort: Option) -> &'static str { + match reasoning_effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + } + } + + fn reasoning_label_for( + model: &str, + reasoning_effort: Option, + ) -> Option<&'static str> { + (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) + } + + pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { + self.chat_widget.token_usage() + } + + fn on_update_reasoning_effort(&mut self, effort: Option) { + self.chat_widget.set_reasoning_effort(effort); + self.config.model_reasoning_effort = effort; + } + + async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char('t'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + // Enter alternate screen and set viewport to full size. + let _ = tui.enter_alt_screen(); + 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 + // with the composer focused and empty. In any other state, forward + // Esc so the active UI (e.g. status indicator, modals, popups) + // handles it. + KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + if self.chat_widget.is_normal_backtrack_mode() + && self.chat_widget.composer_is_empty() + { + self.handle_backtrack_esc_key(tui); + } else { + self.chat_widget.handle_key_event(key_event); + } + } + // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. + KeyEvent { + code: KeyCode::Enter, + kind: KeyEventKind::Press, + .. + } if self.backtrack.primed + && self.backtrack.nth_user_message != usize::MAX + && self.chat_widget.composer_is_empty() => + { + // Delegate to helper for clarity; preserves behavior. + self.confirm_backtrack_from_main(); + } + KeyEvent { + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + // Any non-Esc key press should cancel a primed backtrack. + // This avoids stale "Esc-primed" state after the user starts typing + // (even if they later backspace to empty). + if key_event.code != KeyCode::Esc && self.backtrack.primed { + self.reset_backtrack_state(); + } + self.chat_widget.handle_key_event(key_event); + } + _ => { + // Ignore Release key events. + } + }; + } + + #[cfg(target_os = "windows")] + fn spawn_world_writable_scan( + cwd: PathBuf, + env_map: std::collections::HashMap, + logs_base_dir: PathBuf, + sandbox_policy: codex_core::protocol::SandboxPolicy, + tx: AppEventSender, + ) { + tokio::task::spawn_blocking(move || { + let result = codex_windows_sandbox::apply_world_writable_scan_and_denies( + &logs_base_dir, + &cwd, + &env_map, + &sandbox_policy, + Some(logs_base_dir.as_path()), + ); + if result.is_err() { + // Scan failed: warn without examples. + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: None, + sample_paths: Vec::new(), + extra_count: 0usize, + failed_scan: true, + }); + } + }); + } +} + +fn migration_prompt_allowed_auth_modes(migration_config_key: &str) -> Option<&'static [AuthMode]> { + match migration_config_key { + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_MIGRATION_AUTH_MODES), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_CODEX_MIGRATION_AUTH_MODES), + _ => None, + } +} + +fn migration_prompt_allows_auth_mode( + auth_mode: Option, + migration_config_key: &str, +) -> bool { + if let Some(allowed_modes) = migration_prompt_allowed_auth_modes(migration_config_key) { + match auth_mode { + None => true, + Some(mode) => allowed_modes.contains(&mode), + } + } else { + auth_mode != Some(AuthMode::ApiKey) + } +} + +#[cfg(test)] +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::AskForApproval; + use codex_core::protocol::Event; + use codex_core::protocol::EventMsg; + use codex_core::protocol::SandboxPolicy; + use codex_core::protocol::SessionConfiguredEvent; + use codex_protocol::ConversationId; + use ratatui::prelude::Line; + use std::path::PathBuf; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + + fn make_test_app() -> App { + let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender(); + let config = chat_widget.config_ref().clone(); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + + App { + server, + app_event_tx, + chat_widget, + auth_manager, + config, + current_model, + active_profile: None, + file_search, + transcript_cells: Vec::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), + pending_update_action: None, + suppress_shutdown_complete: false, + skip_world_writable_scan_once: false, + skills: None, + } + } + + fn make_test_app_with_channels() -> ( + App, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender(); + let config = chat_widget.config_ref().clone(); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + + ( + App { + server, + app_event_tx, + chat_widget, + auth_manager, + config, + current_model, + active_profile: None, + file_search, + transcript_cells: Vec::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), + pending_update_action: None, + suppress_shutdown_complete: false, + skip_world_writable_scan_once: false, + skills: None, + }, + rx, + op_rx, + ) + } + + fn all_model_presets() -> Vec { + codex_core::openai_models::model_presets::all_model_presets().clone() + } + + #[test] + fn model_migration_prompt_only_shows_for_deprecated_models() { + assert!(should_show_model_migration_prompt( + "gpt-5", + "gpt-5.1", + None, + all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5-codex", + "gpt-5.1-codex", + None, + all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5-codex-mini", + "gpt-5.1-codex-mini", + None, + all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5.1-codex", + "gpt-5.1-codex-max", + None, + all_model_presets() + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.1-codex", + "gpt-5.1-codex", + None, + all_model_presets() + )); + } + + #[test] + fn model_migration_prompt_respects_hide_flag_and_self_target() { + assert!(!should_show_model_migration_prompt( + "gpt-5", + "gpt-5.1", + Some(true), + all_model_presets() + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.1", + "gpt-5.1", + None, + all_model_presets() + )); + } + + #[test] + fn update_reasoning_effort_updates_config() { + let mut app = make_test_app(); + app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + + app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); + + assert_eq!( + app.config.model_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + app.chat_widget.config_ref().model_reasoning_effort, + 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(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + skill_load_outcome: None, + rollout_path: PathBuf::new(), + }; + Arc::new(new_session_info( + app.chat_widget.config_ref(), + app.current_model.as_str(), + 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)"); + } + + #[tokio::test] + async fn new_session_requests_shutdown_for_previous_conversation() { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels(); + + let conversation_id = ConversationId::new(); + let event = SessionConfiguredEvent { + session_id: conversation_id, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + skill_load_outcome: None, + rollout_path: PathBuf::new(), + }; + + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(event), + }); + + while app_event_rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + + app.shutdown_current_conversation().await; + + match op_rx.try_recv() { + Ok(Op::Shutdown) => {} + Ok(other) => panic!("expected Op::Shutdown, got {other:?}"), + Err(_) => panic!("expected shutdown op to be sent"), + } + } + + #[test] + fn session_summary_skip_zero_usage() { + assert!(session_summary(TokenUsage::default(), None).is_none()); + } + + #[test] + fn session_summary_includes_resume_hint() { + let usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let conversation = + ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + + let summary = session_summary(usage, Some(conversation)).expect("summary"); + assert_eq!( + summary.usage_line, + "Token usage: total=12 input=10 output=2" + ); + assert_eq!( + summary.resume_command, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); + } + + #[test] + fn gpt5_migration_allows_api_key_and_chatgpt() { + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ApiKey), + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG, + )); + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ChatGPT), + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG, + )); + } + + #[test] + fn gpt_5_1_codex_max_migration_limits_to_chatgpt() { + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ChatGPT), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + )); + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ApiKey), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + )); + } + + #[test] + fn other_migrations_block_api_key() { + assert!(!migration_prompt_allows_auth_mode( + Some(AuthMode::ApiKey), + "unknown" + )); + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ChatGPT), + "unknown" + )); + } +} diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs new file mode 100644 index 00000000000..deb629765a2 --- /dev/null +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -0,0 +1,518 @@ +use std::any::TypeId; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::app::App; +use crate::history_cell::SessionInfoCell; +use crate::history_cell::UserHistoryCell; +use crate::pager_overlay::Overlay; +use crate::tui; +use crate::tui::TuiEvent; +use codex_core::protocol::ConversationPathResponseEvent; +use codex_protocol::ConversationId; +use color_eyre::eyre::Result; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; + +/// Aggregates all backtrack-related state used by the App. +#[derive(Default)] +pub(crate) struct BacktrackState { + /// True when Esc has primed backtrack mode in the main view. + pub(crate) primed: bool, + /// Session id of the base conversation to fork from. + pub(crate) base_id: Option, + /// 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, nth_user_message, prefill). + pub(crate) pending: Option<(ConversationId, usize, String)>, +} + +impl App { + /// Route overlay events when transcript overlay is active. + /// - If backtrack preview is active: Esc steps selection; Enter confirms. + /// - Otherwise: Esc begins preview; all other events forward to overlay. + /// interactions (Esc to step target, Enter to confirm) and overlay lifecycle. + pub(crate) async fn handle_backtrack_overlay_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + if self.backtrack.overlay_preview_active { + match event { + TuiEvent::Key(KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack(tui, event)?; + Ok(true) + } + TuiEvent::Key(KeyEvent { + code: KeyCode::Enter, + kind: KeyEventKind::Press, + .. + }) => { + self.overlay_confirm_backtrack(tui); + Ok(true) + } + // Catchall: forward any other events to the overlay widget. + _ => { + self.overlay_forward_event(tui, event)?; + Ok(true) + } + } + } else if let TuiEvent::Key(KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) = event + { + // First Esc in transcript overlay: begin backtrack preview at latest user message. + self.begin_overlay_backtrack_preview(tui); + Ok(true) + } else { + // Not in backtrack mode: forward events to the overlay widget. + self.overlay_forward_event(tui, event)?; + Ok(true) + } + } + + /// Handle global Esc presses for backtracking when no overlay is present. + pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) { + if !self.chat_widget.composer_is_empty() { + return; + } + + if !self.backtrack.primed { + self.prime_backtrack(); + } else if self.overlay.is_none() { + self.open_backtrack_preview(tui); + } else if self.backtrack.overlay_preview_active { + self.step_backtrack_and_highlight(tui); + } + } + + /// Stage a backtrack and request conversation history from the agent. + pub(crate) fn request_backtrack( + &mut self, + prefill: String, + base_id: ConversationId, + nth_user_message: usize, + ) { + self.backtrack.pending = Some((base_id, nth_user_message, prefill)); + if let Some(path) = self.chat_widget.rollout_path() { + let ev = ConversationPathResponseEvent { + conversation_id: base_id, + path, + }; + self.app_event_tx + .send(crate::app_event::AppEvent::ConversationHistory(ev)); + } else { + tracing::error!("rollout path unavailable; cannot backtrack"); + } + } + + /// 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_cells.clone())); + tui.frame_requester().schedule_frame(); + } + + /// Close transcript overlay and restore normal UI. + pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) { + let _ = tui.leave_alt_screen(); + let was_backtrack = self.backtrack.overlay_preview_active; + if !self.deferred_history_lines.is_empty() { + let lines = std::mem::take(&mut self.deferred_history_lines); + tui.insert_history_lines(lines); + } + self.overlay = None; + self.backtrack.overlay_preview_active = false; + if was_backtrack { + // Ensure backtrack state is fully reset when overlay closes (e.g. via 'q'). + self.reset_backtrack_state(); + } + } + + /// 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_cells.is_empty() { + let width = tui.terminal.last_known_screen_size.width; + for cell in &self.transcript_cells { + tui.insert_history_lines(cell.display_lines(width)); + } + } + } + + /// Initialize backtrack state and show composer hint. + fn prime_backtrack(&mut self) { + self.backtrack.primed = true; + self.backtrack.nth_user_message = usize::MAX; + self.backtrack.base_id = self.chat_widget.conversation_id(); + self.chat_widget.show_esc_backtrack_hint(); + } + + /// Open overlay and begin backtrack preview flow (first step + highlight). + fn open_backtrack_preview(&mut self, tui: &mut tui::Tui) { + self.open_transcript_overlay(tui); + self.backtrack.overlay_preview_active = true; + // Composer is hidden by overlay; clear its hint. + self.chat_widget.clear_esc_backtrack_hint(); + self.step_backtrack_and_highlight(tui); + } + + /// When overlay is already open, begin preview mode and select latest user message. + fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) { + self.backtrack.primed = true; + self.backtrack.base_id = self.chat_widget.conversation_id(); + self.backtrack.overlay_preview_active = true; + 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 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) { + 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); + } + } + } + + /// Forward any event to the overlay and close it if done. + fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + if let Some(overlay) = &mut self.overlay { + overlay.handle_event(tui, event)?; + if overlay.is_done() { + self.close_transcript_overlay(tui); + tui.frame_requester().schedule_frame(); + } + } + Ok(()) + } + + /// 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 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); + self.request_backtrack(prefill, base_id, nth_user_message); + } + self.reset_backtrack_state(); + } + + /// Handle Esc in overlay backtrack preview: step selection if armed, else forward. + fn overlay_step_backtrack(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + if self.backtrack.base_id.is_some() { + self.step_backtrack_and_highlight(tui); + } else { + self.overlay_forward_event(tui, event)?; + } + Ok(()) + } + + /// Confirm a primed backtrack from the main view (no overlay visible). + /// 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 = + 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(); + } + + /// Clear all backtrack-related state and composer hints. + pub(crate) fn reset_backtrack_state(&mut self) { + self.backtrack.primed = false; + self.backtrack.base_id = None; + 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(); + } + + /// Handle a ConversationHistory response while a backtrack is pending. + /// If it matches the primed base session, fork and switch to the new conversation. + pub(crate) async fn on_conversation_history_for_backtrack( + &mut self, + tui: &mut tui::Tui, + ev: ConversationPathResponseEvent, + ) -> Result<()> { + if let Some((base_id, _, _)) = self.backtrack.pending.as_ref() + && ev.conversation_id == *base_id + && let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take() + { + self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill) + .await; + } + Ok(()) + } + + /// Fork the conversation using provided history and switch UI/state accordingly. + async fn fork_and_switch_to_new_conversation( + &mut self, + tui: &mut tui::Tui, + ev: ConversationPathResponseEvent, + 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(), nth_user_message, cfg.clone()) + .await; + match result { + Ok(new_conv) => { + self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill) + } + Err(e) => tracing::error!("error forking conversation: {e:#}"), + } + } + + /// Thin wrapper around ConversationManager::fork_conversation. + async fn perform_fork( + &self, + path: PathBuf, + nth_user_message: usize, + cfg: codex_core::config::Config, + ) -> codex_core::error::Result { + self.server + .fork_conversation(nth_user_message, cfg, path) + .await + } + + /// Install a forked conversation into the ChatWidget and update UI to reflect selection. + fn install_forked_conversation( + &mut self, + tui: &mut tui::Tui, + cfg: codex_core::config::Config, + new_conv: codex_core::NewConversation, + nth_user_message: usize, + prefill: &str, + ) { + let conv = new_conv.conversation; + let session_configured = new_conv.session_configured; + let model_family = self.chat_widget.get_model_family(); + let init = crate::chatwidget::ChatWidgetInit { + config: cfg, + model_family: model_family.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + skills: self.skills.clone(), + is_first_run: false, + }; + self.chat_widget = + crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); + self.current_model = model_family.get_model_slug().to_string(); + // Trim transcript up to the selected user message and re-render it. + 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()); + } + tui.frame_requester().schedule_frame(); + } + + /// 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; + } + + 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 session_start_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) == session_start_type) + .map_or(0, |idx| idx + 1); + + cells + .iter() + .enumerate() + .skip(start) + .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(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/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs new file mode 100644 index 00000000000..c92dab4b3a8 --- /dev/null +++ b/codex-rs/tui2/src/app_event.rs @@ -0,0 +1,185 @@ +use std::path::PathBuf; + +use codex_common::approval_presets::ApprovalPreset; +use codex_core::protocol::ConversationPathResponseEvent; +use codex_core::protocol::Event; +use codex_core::protocol::RateLimitSnapshot; +use codex_file_search::FileMatch; +use codex_protocol::openai_models::ModelPreset; + +use crate::bottom_pane::ApprovalRequest; +use crate::history_cell::HistoryCell; + +use codex_core::protocol::AskForApproval; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::openai_models::ReasoningEffort; + +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub(crate) enum AppEvent { + CodexEvent(Event), + + /// Start a new session. + NewSession, + + /// Open the resume picker inside the running TUI session. + OpenResumePicker, + + /// Request to exit the application gracefully. + ExitRequest, + + /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids + /// bubbling channels through layers of widgets. + CodexOp(codex_core::protocol::Op), + + /// Kick off an asynchronous file search for the given query (text after + /// the `@`). Previous searches may be cancelled by the app layer so there + /// is at most one in-flight search. + StartFileSearch(String), + + /// Result of a completed asynchronous file search. The `query` echoes the + /// original search term so the UI can decide whether the results are + /// still relevant. + FileSearchResult { + query: String, + matches: Vec, + }, + + /// Result of refreshing rate limits + RateLimitSnapshotFetched(RateLimitSnapshot), + + /// Result of computing a `/diff` command. + DiffResult(String), + + InsertHistoryCell(Box), + + StartCommitAnimation, + StopCommitAnimation, + CommitTick, + + /// Update the current reasoning effort in the running app and widget. + UpdateReasoningEffort(Option), + + /// Update the current model slug in the running app and widget. + UpdateModel(String), + + /// Persist the selected model and reasoning effort to the appropriate config. + PersistModelSelection { + model: String, + effort: Option, + }, + + /// Open the reasoning selection popup after picking a model. + OpenReasoningPopup { + model: ModelPreset, + }, + + /// Open the full model picker (non-auto models). + OpenAllModelsPopup { + models: Vec, + }, + + /// Open the confirmation prompt before enabling full access mode. + OpenFullAccessConfirmation { + preset: ApprovalPreset, + }, + + /// Open the Windows world-writable directories warning. + /// If `preset` is `Some`, the confirmation will apply the provided + /// approval/sandbox configuration on Continue; if `None`, it performs no + /// policy change and only acknowledges/dismisses the warning. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + OpenWorldWritableWarningConfirmation { + preset: Option, + /// Up to 3 sample world-writable directories to display in the warning. + sample_paths: Vec, + /// If there are more than `sample_paths`, this carries the remaining count. + extra_count: usize, + /// True when the scan failed (e.g. ACL query error) and protections could not be verified. + failed_scan: bool, + }, + + /// Prompt to enable the Windows sandbox feature before using Agent mode. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + OpenWindowsSandboxEnablePrompt { + preset: ApprovalPreset, + }, + + /// Enable the Windows sandbox feature and switch to Agent mode. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + EnableWindowsSandboxForAgentMode { + preset: ApprovalPreset, + }, + + /// Update the current approval policy in the running app and widget. + UpdateAskForApprovalPolicy(AskForApproval), + + /// Update the current sandbox policy in the running app and widget. + UpdateSandboxPolicy(SandboxPolicy), + + /// Update whether the full access warning prompt has been acknowledged. + UpdateFullAccessWarningAcknowledged(bool), + + /// Update whether the world-writable directories warning has been acknowledged. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + UpdateWorldWritableWarningAcknowledged(bool), + + /// Update whether the rate limit switch prompt has been acknowledged for the session. + UpdateRateLimitSwitchPromptHidden(bool), + + /// Persist the acknowledgement flag for the full access warning prompt. + PersistFullAccessWarningAcknowledged, + + /// Persist the acknowledgement flag for the world-writable directories warning. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + PersistWorldWritableWarningAcknowledged, + + /// Persist the acknowledgement flag for the rate limit switch prompt. + PersistRateLimitSwitchPromptHidden, + + /// Persist the acknowledgement flag for the model migration prompt. + PersistModelMigrationPromptAcknowledged { + migration_config: String, + }, + + /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + SkipNextWorldWritableScan, + + /// Re-open the approval presets popup. + OpenApprovalsPopup, + + /// 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 approval popup. + FullScreenApprovalRequest(ApprovalRequest), + + /// Open the feedback note entry overlay after the user selects a category. + OpenFeedbackNote { + category: FeedbackCategory, + include_logs: bool, + }, + + /// Open the upload consent popup for feedback after selecting a category. + OpenFeedbackConsent { + category: FeedbackCategory, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FeedbackCategory { + BadResult, + GoodResult, + Bug, + Other, +} diff --git a/codex-rs/tui2/src/app_event_sender.rs b/codex-rs/tui2/src/app_event_sender.rs new file mode 100644 index 00000000000..c1427b3ff02 --- /dev/null +++ b/codex-rs/tui2/src/app_event_sender.rs @@ -0,0 +1,28 @@ +use tokio::sync::mpsc::UnboundedSender; + +use crate::app_event::AppEvent; +use crate::session_log; + +#[derive(Clone, Debug)] +pub(crate) struct AppEventSender { + pub app_event_tx: UnboundedSender, +} + +impl AppEventSender { + pub(crate) fn new(app_event_tx: UnboundedSender) -> Self { + Self { app_event_tx } + } + + /// Send an event to the app event channel. If it fails, we swallow the + /// error and log it. + pub(crate) fn send(&self, event: AppEvent) { + // Record inbound events for high-fidelity session replay. + // Avoid double-logging Ops; those are logged at the point of submission. + if !matches!(event, AppEvent::CodexOp(_)) { + session_log::log_inbound_app_event(&event); + } + if let Err(e) = self.app_event_tx.send(event) { + tracing::error!("failed to send event: {e}"); + } + } +} diff --git a/codex-rs/tui2/src/ascii_animation.rs b/codex-rs/tui2/src/ascii_animation.rs new file mode 100644 index 00000000000..b2d9fc1d196 --- /dev/null +++ b/codex-rs/tui2/src/ascii_animation.rs @@ -0,0 +1,111 @@ +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 + } + + #[allow(dead_code)] + 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/tui2/src/bin/md-events2.rs b/codex-rs/tui2/src/bin/md-events2.rs new file mode 100644 index 00000000000..f1117fad91d --- /dev/null +++ b/codex-rs/tui2/src/bin/md-events2.rs @@ -0,0 +1,15 @@ +use std::io::Read; +use std::io::{self}; + +fn main() { + let mut input = String::new(); + if let Err(err) = io::stdin().read_to_string(&mut input) { + eprintln!("failed to read stdin: {err}"); + std::process::exit(1); + } + + let parser = pulldown_cmark::Parser::new(&input); + for event in parser { + println!("{event:?}"); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/approval_overlay.rs b/codex-rs/tui2/src/bottom_pane/approval_overlay.rs new file mode 100644 index 00000000000..d42861eb1d5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/approval_overlay.rs @@ -0,0 +1,717 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::list_selection_view::ListSelectionView; +use crate::bottom_pane::list_selection_view::SelectionItem; +use crate::bottom_pane::list_selection_view::SelectionViewParams; +use crate::diff_render::DiffSummary; +use crate::exec_command::strip_bash_lc_and_escape; +use crate::history_cell; +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::render::highlight::highlight_bash_to_lines; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use codex_core::features::Feature; +use codex_core::features::Features; +use codex_core::protocol::ElicitationAction; +use codex_core::protocol::ExecPolicyAmendment; +use codex_core::protocol::FileChange; +use codex_core::protocol::Op; +use codex_core::protocol::ReviewDecision; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use mcp_types::RequestId; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; + +/// Request coming from the agent that needs user approval. +#[derive(Clone, Debug)] +pub(crate) enum ApprovalRequest { + Exec { + id: String, + command: Vec, + reason: Option, + proposed_execpolicy_amendment: Option, + }, + ApplyPatch { + id: String, + reason: Option, + cwd: PathBuf, + changes: HashMap, + }, + McpElicitation { + server_name: String, + request_id: RequestId, + message: String, + }, +} + +/// Modal overlay asking the user to approve or deny one or more requests. +pub(crate) struct ApprovalOverlay { + current_request: Option, + current_variant: Option, + queue: Vec, + app_event_tx: AppEventSender, + list: ListSelectionView, + options: Vec, + current_complete: bool, + done: bool, + features: Features, +} + +impl ApprovalOverlay { + pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self { + let mut view = Self { + current_request: None, + current_variant: None, + queue: Vec::new(), + app_event_tx: app_event_tx.clone(), + list: ListSelectionView::new(Default::default(), app_event_tx), + options: Vec::new(), + current_complete: false, + done: false, + features, + }; + view.set_current(request); + view + } + + pub fn enqueue_request(&mut self, req: ApprovalRequest) { + self.queue.push(req); + } + + fn set_current(&mut self, request: ApprovalRequest) { + self.current_request = Some(request.clone()); + let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request); + self.current_variant = Some(variant.clone()); + self.current_complete = false; + let (options, params) = Self::build_options(variant, header, &self.features); + self.options = options; + self.list = ListSelectionView::new(params, self.app_event_tx.clone()); + } + + fn build_options( + variant: ApprovalVariant, + header: Box, + features: &Features, + ) -> (Vec, SelectionViewParams) { + let (options, title) = match &variant { + ApprovalVariant::Exec { + proposed_execpolicy_amendment, + .. + } => ( + exec_options(proposed_execpolicy_amendment.clone(), features), + "Would you like to run the following command?".to_string(), + ), + ApprovalVariant::ApplyPatch { .. } => ( + patch_options(), + "Would you like to make the following edits?".to_string(), + ), + ApprovalVariant::McpElicitation { server_name, .. } => ( + elicitation_options(), + format!("{server_name} needs your approval."), + ), + }; + + let header = Box::new(ColumnRenderable::with([ + Line::from(title.bold()).into(), + Line::from("").into(), + header, + ])); + + let items = options + .iter() + .map(|opt| SelectionItem { + name: opt.label.clone(), + display_shortcut: opt + .display_shortcut + .or_else(|| opt.additional_shortcuts.first().copied()), + dismiss_on_select: false, + ..Default::default() + }) + .collect(); + + let params = SelectionViewParams { + footer_hint: Some(Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to confirm or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to cancel".into(), + ])), + items, + header, + ..Default::default() + }; + + (options, params) + } + + fn apply_selection(&mut self, actual_idx: usize) { + if self.current_complete { + return; + } + let Some(option) = self.options.get(actual_idx) else { + return; + }; + if let Some(variant) = self.current_variant.as_ref() { + match (variant, &option.decision) { + (ApprovalVariant::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { + self.handle_exec_decision(id, command, decision.clone()); + } + (ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { + self.handle_patch_decision(id, decision.clone()); + } + ( + ApprovalVariant::McpElicitation { + server_name, + request_id, + }, + ApprovalDecision::McpElicitation(decision), + ) => { + self.handle_elicitation_decision(server_name, request_id, *decision); + } + _ => {} + } + } + + self.current_complete = true; + self.advance_queue(); + } + + fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { + let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone()); + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { + id: id.to_string(), + decision, + })); + } + + fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) { + self.app_event_tx.send(AppEvent::CodexOp(Op::PatchApproval { + id: id.to_string(), + decision, + })); + } + + fn handle_elicitation_decision( + &self, + server_name: &str, + request_id: &RequestId, + decision: ElicitationAction, + ) { + self.app_event_tx + .send(AppEvent::CodexOp(Op::ResolveElicitation { + server_name: server_name.to_string(), + request_id: request_id.clone(), + decision, + })); + } + + fn advance_queue(&mut self) { + if let Some(next) = self.queue.pop() { + self.set_current(next); + } else { + self.done = true; + } + } + + fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { + match key_event { + KeyEvent { + kind: KeyEventKind::Press, + code: KeyCode::Char('a'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(request) = self.current_request.as_ref() { + self.app_event_tx + .send(AppEvent::FullScreenApprovalRequest(request.clone())); + true + } else { + false + } + } + e => { + if let Some(idx) = self + .options + .iter() + .position(|opt| opt.shortcuts().any(|s| s.is_press(*e))) + { + self.apply_selection(idx); + true + } else { + false + } + } + } + } +} + +impl BottomPaneView for ApprovalOverlay { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if self.try_handle_shortcut(&key_event) { + return; + } + self.list.handle_key_event(key_event); + if let Some(idx) = self.list.take_last_selected_index() { + self.apply_selection(idx); + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + if self.done { + return CancellationEvent::Handled; + } + if !self.current_complete + && let Some(variant) = self.current_variant.as_ref() + { + match &variant { + ApprovalVariant::Exec { id, command, .. } => { + self.handle_exec_decision(id, command, ReviewDecision::Abort); + } + ApprovalVariant::ApplyPatch { id, .. } => { + self.handle_patch_decision(id, ReviewDecision::Abort); + } + ApprovalVariant::McpElicitation { + server_name, + request_id, + } => { + self.handle_elicitation_decision( + server_name, + request_id, + ElicitationAction::Cancel, + ); + } + } + } + self.queue.clear(); + self.done = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.done + } + + fn try_consume_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Option { + self.enqueue_request(request); + None + } +} + +impl Renderable for ApprovalOverlay { + fn desired_height(&self, width: u16) -> u16 { + self.list.desired_height(width) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.list.render(area, buf); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.list.cursor_pos(area) + } +} + +struct ApprovalRequestState { + variant: ApprovalVariant, + header: Box, +} + +impl From for ApprovalRequestState { + fn from(value: ApprovalRequest) -> Self { + match value { + ApprovalRequest::Exec { + id, + command, + reason, + proposed_execpolicy_amendment, + } => { + let mut header: Vec> = Vec::new(); + if let Some(reason) = reason { + header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); + header.push(Line::from("")); + } + let full_cmd = strip_bash_lc_and_escape(&command); + let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); + if let Some(first) = full_cmd_lines.first_mut() { + first.spans.insert(0, Span::from("$ ")); + } + header.extend(full_cmd_lines); + Self { + variant: ApprovalVariant::Exec { + id, + command, + proposed_execpolicy_amendment, + }, + header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), + } + } + ApprovalRequest::ApplyPatch { + id, + reason, + cwd, + changes, + } => { + let mut header: Vec> = Vec::new(); + if let Some(reason) = reason + && !reason.is_empty() + { + header.push(Box::new( + Paragraph::new(Line::from_iter(["Reason: ".into(), reason.italic()])) + .wrap(Wrap { trim: false }), + )); + header.push(Box::new(Line::from(""))); + } + header.push(DiffSummary::new(changes, cwd).into()); + Self { + variant: ApprovalVariant::ApplyPatch { id }, + header: Box::new(ColumnRenderable::with(header)), + } + } + ApprovalRequest::McpElicitation { + server_name, + request_id, + message, + } => { + let header = Paragraph::new(vec![ + Line::from(vec!["Server: ".into(), server_name.clone().bold()]), + Line::from(""), + Line::from(message), + ]) + .wrap(Wrap { trim: false }); + Self { + variant: ApprovalVariant::McpElicitation { + server_name, + request_id, + }, + header: Box::new(header), + } + } + } + } +} + +#[derive(Clone)] +enum ApprovalVariant { + Exec { + id: String, + command: Vec, + proposed_execpolicy_amendment: Option, + }, + ApplyPatch { + id: String, + }, + McpElicitation { + server_name: String, + request_id: RequestId, + }, +} + +#[derive(Clone)] +enum ApprovalDecision { + Review(ReviewDecision), + McpElicitation(ElicitationAction), +} + +#[derive(Clone)] +struct ApprovalOption { + label: String, + decision: ApprovalDecision, + display_shortcut: Option, + additional_shortcuts: Vec, +} + +impl ApprovalOption { + fn shortcuts(&self) -> impl Iterator + '_ { + self.display_shortcut + .into_iter() + .chain(self.additional_shortcuts.iter().copied()) + } +} + +fn exec_options( + proposed_execpolicy_amendment: Option, + features: &Features, +) -> Vec { + vec![ApprovalOption { + label: "Yes, proceed".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }] + .into_iter() + .chain( + proposed_execpolicy_amendment + .filter(|_| features.enabled(Feature::ExecPolicy)) + .map(|prefix| { + let rendered_prefix = strip_bash_lc_and_escape(prefix.command()); + ApprovalOption { + label: format!( + "Yes, and don't ask again for commands that start with `{rendered_prefix}`" + ), + decision: ApprovalDecision::Review( + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: prefix, + }, + ), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + } + }), + ) + .chain([ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }]) + .collect() +} + +fn patch_options() -> Vec { + vec![ + ApprovalOption { + label: "Yes, proceed".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }, + ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ] +} + +fn elicitation_options() -> Vec { + vec![ + ApprovalOption { + label: "Yes, provide the requested info".to_string(), + decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }, + ApprovalOption { + label: "No, but continue without it".to_string(), + decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ApprovalOption { + label: "Cancel this request".to_string(), + decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('c'))], + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn make_exec_request() -> ApprovalRequest { + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + reason: Some("reason".to_string()), + proposed_execpolicy_amendment: None, + } + } + + #[test] + fn ctrl_c_aborts_and_clears_queue() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + view.enqueue_request(make_exec_request()); + assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); + assert!(view.queue.is_empty()); + assert!(view.is_complete()); + } + + #[test] + fn shortcut_triggers_selection() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + assert!(!view.is_complete()); + view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + // We expect at least one CodexOp message in the queue. + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if matches!(ev, AppEvent::CodexOp(_)) { + saw_op = true; + break; + } + } + assert!(saw_op, "expected approval decision to emit an op"); + } + + #[test] + fn exec_prefix_option_emits_execpolicy_amendment() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string()], + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + ])), + }, + tx, + Features::with_defaults(), + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { + assert_eq!( + decision, + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![ + "echo".to_string() + ]) + } + ); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected approval decision to emit an op with command prefix" + ); + } + + #[test] + fn exec_prefix_option_hidden_when_execpolicy_disabled() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string()], + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + ])), + }, + tx, + { + let mut features = Features::with_defaults(); + features.disable(Feature::ExecPolicy); + features + }, + ); + assert_eq!(view.options.len(), 2); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + assert!(!view.is_complete()); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn header_includes_command_snippet() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let command = vec!["echo".into(), "hello".into(), "world".into()]; + let exec_request = ApprovalRequest::Exec { + id: "test".into(), + command, + reason: None, + proposed_execpolicy_amendment: None, + }; + + let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80))); + view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf); + + let rendered: Vec = (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect() + }) + .collect(); + assert!( + rendered + .iter() + .any(|line| line.contains("echo hello world")), + "expected header to include command snippet, got {rendered:?}" + ); + } + + #[test] + fn exec_history_cell_wraps_with_two_space_indent() { + let command = vec![ + "/bin/zsh".into(), + "-lc".into(), + "git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(), + ]; + let cell = history_cell::new_approval_decision_cell(command, ReviewDecision::Approved); + let lines = cell.display_lines(28); + let rendered: Vec = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + let expected = vec![ + "✔ You approved codex to run".to_string(), + " git add tui/src/render/".to_string(), + " mod.rs tui/src/render/".to_string(), + " renderable.rs this time".to_string(), + ]; + assert_eq!(rendered, expected); + } + + #[test] + fn enter_sets_last_selected_index_without_dismissing() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!( + view.is_complete(), + "exec approval should complete without queued requests" + ); + + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ExecApproval { decision: d, .. }) = ev { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(ReviewDecision::Approved)); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs new file mode 100644 index 00000000000..499801cbb09 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs @@ -0,0 +1,37 @@ +use crate::bottom_pane::ApprovalRequest; +use crate::render::renderable::Renderable; +use crossterm::event::KeyEvent; + +use super::CancellationEvent; + +/// Trait implemented by every view that can be shown in the bottom pane. +pub(crate) trait BottomPaneView: Renderable { + /// Handle a key event while the view is active. A redraw is always + /// scheduled after this call. + 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 { + false + } + + /// Handle Ctrl-C while this view is active. + fn on_ctrl_c(&mut self) -> CancellationEvent { + CancellationEvent::NotHandled + } + + /// Optional paste handler. Return true if the view modified its state and + /// needs a redraw. + fn handle_paste(&mut self, _pasted: String) -> bool { + false + } + + /// Try to handle approval request; return the original value if not + /// consumed. + fn try_consume_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Option { + Some(request) + } +} diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs new file mode 100644 index 00000000000..ed498e949c6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -0,0 +1,3990 @@ +use crate::key_hint::has_ctrl_or_alt; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Margin; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::WidgetRef; + +use super::chat_composer_history::ChatComposerHistory; +use super::command_popup::CommandItem; +use super::command_popup::CommandPopup; +use super::file_search_popup::FileSearchPopup; +use super::footer::FooterMode; +use super::footer::FooterProps; +use super::footer::esc_hint_mode; +use super::footer::footer_height; +use super::footer::render_footer; +use super::footer::reset_mode_after_activity; +use super::footer::toggle_shortcut_mode; +use super::paste_burst::CharDecision; +use super::paste_burst::PasteBurst; +use super::skill_popup::SkillPopup; +use crate::bottom_pane::paste_burst::FlushResult; +use crate::bottom_pane::prompt_args::expand_custom_prompt; +use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; +use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::bottom_pane::prompt_args::prompt_argument_names; +use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; +use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; +use crate::render::Insets; +use crate::render::RectExt; +use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command::built_in_slash_commands; +use crate::style::user_message_style; +use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; +use crate::clipboard_paste::normalize_pasted_path; +use crate::clipboard_paste::pasted_image_format; +use crate::history_cell; +use crate::ui_consts::LIVE_PREFIX_COLS; +use codex_core::skills::model::SkillMetadata; +use codex_file_search::FileMatch; +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +/// If the pasted content exceeds this number of characters, replace it with a +/// placeholder in the UI. +const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; + +/// Result returned when the user interacts with the text area. +#[derive(Debug, PartialEq)] +pub enum InputResult { + Submitted(String), + Command(SlashCommand), + None, +} + +#[derive(Clone, Debug, PartialEq)] +struct AttachedImage { + placeholder: String, + path: PathBuf, +} + +enum PromptSelectionMode { + Completion, + Submit, +} + +enum PromptSelectionAction { + Insert { text: String, cursor: Option }, + Submit { text: String }, +} + +pub(crate) struct ChatComposer { + textarea: TextArea, + textarea_state: RefCell, + active_popup: ActivePopup, + app_event_tx: AppEventSender, + history: ChatComposerHistory, + ctrl_c_quit_hint: bool, + esc_backtrack_hint: bool, + use_shift_enter_hint: bool, + dismissed_file_popup_token: Option, + current_file_query: Option, + pending_pastes: Vec<(String, String)>, + large_paste_counters: HashMap, + has_focus: bool, + attached_images: Vec, + placeholder_text: String, + is_task_running: bool, + // Non-bracketed paste burst tracker. + paste_burst: PasteBurst, + // When true, disables paste-burst logic and inserts characters immediately. + disable_paste_burst: bool, + custom_prompts: Vec, + footer_mode: FooterMode, + footer_hint_override: Option>, + context_window_percent: Option, + context_window_used_tokens: Option, + skills: Option>, + dismissed_skill_popup_token: Option, +} + +/// Popup state – at most one can be visible at any time. +enum ActivePopup { + None, + Command(CommandPopup), + File(FileSearchPopup), + Skill(SkillPopup), +} + +const FOOTER_SPACING_HEIGHT: u16 = 0; + +impl ChatComposer { + pub fn new( + has_input_focus: bool, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + placeholder_text: String, + disable_paste_burst: bool, + ) -> Self { + let use_shift_enter_hint = enhanced_keys_supported; + + let mut this = Self { + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + active_popup: ActivePopup::None, + app_event_tx, + history: ChatComposerHistory::new(), + ctrl_c_quit_hint: false, + esc_backtrack_hint: false, + use_shift_enter_hint, + dismissed_file_popup_token: None, + current_file_query: None, + pending_pastes: Vec::new(), + large_paste_counters: HashMap::new(), + has_focus: has_input_focus, + attached_images: Vec::new(), + placeholder_text, + is_task_running: false, + paste_burst: PasteBurst::default(), + disable_paste_burst: false, + custom_prompts: Vec::new(), + footer_mode: FooterMode::ShortcutSummary, + footer_hint_override: None, + context_window_percent: None, + context_window_used_tokens: None, + skills: None, + dismissed_skill_popup_token: None, + }; + // Apply configuration via the setter to keep side-effects centralized. + this.set_disable_paste_burst(disable_paste_burst); + this + } + + pub fn set_skill_mentions(&mut self, skills: Option>) { + self.skills = skills; + } + + fn layout_areas(&self, area: Rect) -> [Rect; 3] { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let footer_total_height = footer_hint_height + footer_spacing; + let popup_constraint = match &self.active_popup { + ActivePopup::Command(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } + ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), + ActivePopup::Skill(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } + ActivePopup::None => Constraint::Max(footer_total_height), + }; + let [composer_rect, popup_rect] = + Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area); + let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1)); + [composer_rect, textarea_rect, popup_rect] + } + + fn footer_spacing(footer_hint_height: u16) -> u16 { + if footer_hint_height == 0 { + 0 + } else { + FOOTER_SPACING_HEIGHT + } + } + + /// Returns true if the composer currently contains no user input. + pub(crate) fn is_empty(&self) -> bool { + self.textarea.is_empty() + } + + /// Record the history metadata advertised by `SessionConfiguredEvent` so + /// that the composer can navigate cross-session history. + pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { + self.history.set_metadata(log_id, entry_count); + } + + /// Integrate an asynchronous response to an on-demand history lookup. If + /// the entry is present and the offset matches the current cursor we + /// immediately populate the textarea. + pub(crate) fn on_history_entry_response( + &mut self, + log_id: u64, + offset: usize, + entry: Option, + ) -> bool { + let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { + return false; + }; + self.set_text_content(text); + true + } + + pub fn handle_paste(&mut self, pasted: String) -> bool { + let char_count = pasted.chars().count(); + if char_count > LARGE_PASTE_CHAR_THRESHOLD { + let placeholder = self.next_large_paste_placeholder(char_count); + self.textarea.insert_element(&placeholder); + self.pending_pastes.push((placeholder, pasted)); + } else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) { + self.textarea.insert_str(" "); + } else { + self.textarea.insert_str(&pasted); + } + // Explicit paste events should not trigger Enter suppression. + self.paste_burst.clear_after_explicit_paste(); + self.sync_popups(); + true + } + + pub fn handle_paste_image_path(&mut self, pasted: String) -> bool { + let Some(path_buf) = normalize_pasted_path(&pasted) else { + return false; + }; + + // normalize_pasted_path already handles Windows → WSL path conversion, + // so we can directly try to read the image dimensions. + match image::image_dimensions(&path_buf) { + Ok((w, h)) => { + tracing::info!("OK: {pasted}"); + let format_label = pasted_image_format(&path_buf).label(); + self.attach_image(path_buf, w, h, format_label); + true + } + Err(err) => { + tracing::trace!("ERR: {err}"); + false + } + } + } + + pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { + let was_disabled = self.disable_paste_burst; + self.disable_paste_burst = disabled; + if disabled && !was_disabled { + self.paste_burst.clear_window_after_non_char(); + } + } + + /// Override the footer hint items displayed beneath the composer. Passing + /// `None` restores the default shortcut footer. + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.footer_hint_override = items; + } + + /// Replace the entire composer content with `text` and reset cursor. + pub(crate) fn set_text_content(&mut self, text: String) { + // Clear any existing content, placeholders, and attachments first. + self.textarea.set_text(""); + self.pending_pastes.clear(); + self.attached_images.clear(); + self.textarea.set_text(&text); + self.textarea.set_cursor(0); + self.sync_popups(); + } + + pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { + if self.is_empty() { + return None; + } + let previous = self.current_text(); + self.set_text_content(String::new()); + self.history.reset_navigation(); + self.history.record_local_submission(&previous); + Some(previous) + } + + /// Get the current composer text. + pub(crate) fn current_text(&self) -> String { + self.textarea.text().to_string() + } + + /// Attempt to start a burst by retro-capturing recent chars before the cursor. + pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, _format_label: &str) { + let file_label = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "image".to_string()); + let placeholder = format!("[{file_label} {width}x{height}]"); + // Insert as an element to match large paste placeholder behavior: + // styled distinctly and treated atomically for cursor/mutations. + self.textarea.insert_element(&placeholder); + self.attached_images + .push(AttachedImage { placeholder, path }); + } + + pub fn take_recent_submission_images(&mut self) -> Vec { + let images = std::mem::take(&mut self.attached_images); + images.into_iter().map(|img| img.path).collect() + } + + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + self.handle_paste_burst_flush(Instant::now()) + } + + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.paste_burst.is_active() + } + + pub(crate) fn recommended_paste_flush_delay() -> Duration { + PasteBurst::recommended_flush_delay() + } + + /// Integrate results from an asynchronous file search. + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + // Only apply if user is still editing a token starting with `query`. + let current_opt = Self::current_at_token(&self.textarea); + let Some(current_token) = current_opt else { + return; + }; + + if !current_token.starts_with(&query) { + return; + } + + if let ActivePopup::File(popup) = &mut self.active_popup { + popup.set_matches(&query, matches); + } + } + + pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { + self.ctrl_c_quit_hint = show; + if show { + self.footer_mode = FooterMode::CtrlCReminder; + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + self.set_has_focus(has_focus); + } + + fn next_large_paste_placeholder(&mut self, char_count: usize) -> String { + let base = format!("[Pasted Content {char_count} chars]"); + let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0); + *next_suffix += 1; + if *next_suffix == 1 { + base + } else { + format!("{base} #{next_suffix}") + } + } + + pub(crate) fn insert_str(&mut self, text: &str) { + self.textarea.insert_str(text); + self.sync_popups(); + } + + /// Handle a key event coming from the main UI. + pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + 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), + ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), + ActivePopup::None => self.handle_key_event_without_popup(key_event), + }; + + // Update (or hide/show) popup after processing the key. + self.sync_popups(); + + result + } + + /// Return true if either the slash-command popup or the file-search popup is active. + pub(crate) fn popup_active(&self) -> bool { + !matches!(self.active_popup, ActivePopup::None) + } + + /// Handle key event when the slash-command popup is visible. + fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + let ActivePopup::Command(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + // Dismiss the slash popup; keep the current input untouched. + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } => { + // Ensure popup filtering/selection reflects the latest composer text + // before applying completion. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + popup.on_composer_text_change(first_line.to_string()); + if let Some(sel) = popup.selected_item() { + let mut cursor_target: Option = None; + match sel { + CommandItem::Builtin(cmd) => { + if cmd == SlashCommand::Skills { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + + let starts_with_cmd = first_line + .trim_start() + .starts_with(&format!("/{}", cmd.command())); + if !starts_with_cmd { + self.textarea.set_text(&format!("/{} ", cmd.command())); + } + if !self.textarea.text().is_empty() { + cursor_target = Some(self.textarea.text().len()); + } + } + CommandItem::UserPrompt(idx) => { + if let Some(prompt) = popup.prompt(idx) { + match prompt_selection_action( + prompt, + first_line, + PromptSelectionMode::Completion, + ) { + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text(&text); + cursor_target = Some(target); + } + PromptSelectionAction::Submit { .. } => {} + } + } + } + } + if let Some(pos) = cursor_target { + self.textarea.set_cursor(pos); + } + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + // If the current line starts with a custom prompt name and includes + // positional args for a numeric-style template, expand and submit + // immediately regardless of the popup selection. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, _rest)) = parse_slash_name(first_line) + && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) + && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) + && let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line) + { + self.textarea.set_text(""); + return (InputResult::Submitted(expanded), true); + } + + if let Some(sel) = popup.selected_item() { + match sel { + CommandItem::Builtin(cmd) => { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + CommandItem::UserPrompt(idx) => { + if let Some(prompt) = popup.prompt(idx) { + match prompt_selection_action( + prompt, + first_line, + PromptSelectionMode::Submit, + ) { + PromptSelectionAction::Submit { text } => { + self.textarea.set_text(""); + return (InputResult::Submitted(text), true); + } + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text(&text); + self.textarea.set_cursor(target); + return (InputResult::None, true); + } + } + } + return (InputResult::None, true); + } + } + } + // Fallback to default newline handling if no command selected. + self.handle_key_event_without_popup(key_event) + } + input => self.handle_input_basic(input), + } + } + + #[inline] + fn clamp_to_char_boundary(text: &str, pos: usize) -> usize { + let mut p = pos.min(text.len()); + if p < text.len() && !text.is_char_boundary(p) { + p = text + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= p) + .last() + .unwrap_or(0); + } + p + } + + #[inline] + fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) { + if let KeyEvent { + code: KeyCode::Char(ch), + .. + } = input + { + let now = Instant::now(); + if self.paste_burst.try_append_char_if_active(ch, now) { + return (InputResult::None, true); + } + } + if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + self.textarea.input(input); + let text_after = self.textarea.text(); + self.pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + (InputResult::None, true) + } + + /// Handle key events when file search popup is visible. + fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + let ActivePopup::File(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + // Hide popup without modifying text, remember token to avoid immediate reopen. + if let Some(tok) = Self::current_at_token(&self.textarea) { + self.dismissed_file_popup_token = Some(tok); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let Some(sel) = popup.selected_match() else { + self.active_popup = ActivePopup::None; + return (InputResult::None, true); + }; + + let sel_path = sel.to_string(); + // If selected path looks like an image (png/jpeg), attach as image instead of inserting text. + let is_image = Self::is_image_path(&sel_path); + if is_image { + // Determine dimensions; if that fails fall back to normal path insertion. + let path_buf = PathBuf::from(&sel_path); + if let Ok((w, h)) = image::image_dimensions(&path_buf) { + // Remove the current @token (mirror logic from insert_selected_path without inserting text) + // using the flat text and byte-offset cursor API. + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + // Clamp to a valid char boundary to avoid panics when slicing. + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + // Determine token boundaries in the full text. + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + self.textarea.replace_range(start_idx..end_idx, ""); + self.textarea.set_cursor(start_idx); + + let format_label = match Path::new(&sel_path) + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + { + Some(ext) if ext == "png" => "PNG", + Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG", + _ => "IMG", + }; + self.attach_image(path_buf, w, h, format_label); + // Add a trailing space to keep typing fluid. + self.textarea.insert_str(" "); + } else { + // Fallback to plain path insertion if metadata read fails. + self.insert_selected_path(&sel_path); + } + } else { + // Non-image: inserting file path. + self.insert_selected_path(&sel_path); + } + // No selection: treat Enter as closing the popup/session. + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + } + } + + fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + let ActivePopup::Skill(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + if let Some(tok) = self.current_skill_token() { + self.dismissed_skill_popup_token = Some(tok); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let selected = popup.selected_skill().map(|skill| skill.name.clone()); + if let Some(name) = selected { + self.insert_selected_skill(&name); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + } + } + + fn is_image_path(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") + } + + fn skills_enabled(&self) -> bool { + self.skills.as_ref().is_some_and(|s| !s.is_empty()) + } + + /// Extract a token prefixed with `prefix` under the cursor, if any. + /// + /// The returned string **does not** include the prefix. + /// + /// Behavior: + /// - The cursor may be anywhere *inside* the token (including on the + /// leading prefix). It does **not** need to be at the end of the line. + /// - A token is delimited by ASCII whitespace (space, tab, newline). + /// - If the token under the cursor starts with `prefix`, that token is + /// returned without the leading prefix. When `allow_empty` is true, a + /// lone prefix character yields `Some(String::new())` to surface hints. + fn current_prefixed_token( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + ) -> Option { + let cursor_offset = textarea.cursor(); + let text = textarea.text(); + + // Adjust the provided byte offset to the nearest valid char boundary at or before it. + let mut safe_cursor = cursor_offset.min(text.len()); + // If we're not on a char boundary, move back to the start of the current char. + if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) { + // Find the last valid boundary <= cursor_offset. + safe_cursor = text + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= cursor_offset) + .last() + .unwrap_or(0); + } + + // Split the line around the (now safe) cursor position. + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + // Detect whether we're on whitespace at the cursor boundary. + let at_whitespace = if safe_cursor < text.len() { + text[safe_cursor..] + .chars() + .next() + .map(char::is_whitespace) + .unwrap_or(false) + } else { + false + }; + + // Left candidate: token containing the cursor position. + let start_left = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + let end_left_rel = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_left = safe_cursor + end_left_rel; + let token_left = if start_left < end_left { + Some(&text[start_left..end_left]) + } else { + None + }; + + // Right candidate: token immediately after any whitespace from the cursor. + let ws_len_right: usize = after_cursor + .chars() + .take_while(|c| c.is_whitespace()) + .map(char::len_utf8) + .sum(); + let start_right = safe_cursor + ws_len_right; + let end_right_rel = text[start_right..] + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(text.len() - start_right); + let end_right = start_right + end_right_rel; + let token_right = if start_right < end_right { + Some(&text[start_right..end_right]) + } else { + None + }; + + let prefix_str = prefix.to_string(); + let left_match = token_left.filter(|t| t.starts_with(prefix)); + let right_match = token_right.filter(|t| t.starts_with(prefix)); + + let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string()); + let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string()); + + if at_whitespace { + if right_prefixed.is_some() { + return right_prefixed; + } + if token_left.is_some_and(|t| t == prefix_str) { + return allow_empty.then(String::new); + } + return left_prefixed; + } + if after_cursor.starts_with(prefix) { + return right_prefixed.or(left_prefixed); + } + left_prefixed.or(right_prefixed) + } + + /// Extract the `@token` that the cursor is currently positioned on, if any. + /// + /// The returned string **does not** include the leading `@`. + fn current_at_token(textarea: &TextArea) -> Option { + Self::current_prefixed_token(textarea, '@', false) + } + + fn current_skill_token(&self) -> Option { + if !self.skills_enabled() { + return None; + } + Self::current_prefixed_token(&self.textarea, '$', true) + } + + /// Replace the active `@token` (the one under the cursor) with `path`. + /// + /// The algorithm mirrors `current_at_token` so replacement works no matter + /// where the cursor is within the token and regardless of how many + /// `@tokens` exist in the line. + fn insert_selected_path(&mut self, path: &str) { + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + // Clamp to a valid char boundary to avoid panics when slicing. + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + // Determine token boundaries. + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + // If the path contains whitespace, wrap it in double quotes so the + // local prompt arg parser treats it as a single argument. Avoid adding + // quotes when the path already contains one to keep behavior simple. + let needs_quotes = path.chars().any(char::is_whitespace); + let inserted = if needs_quotes && !path.contains('"') { + format!("\"{path}\"") + } else { + path.to_string() + }; + + // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. + let mut new_text = + String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); + new_text.push_str(&text[..start_idx]); + new_text.push_str(&inserted); + new_text.push(' '); + new_text.push_str(&text[end_idx..]); + + self.textarea.set_text(&new_text); + let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); + self.textarea.set_cursor(new_cursor); + } + + fn insert_selected_skill(&mut self, skill_name: &str) { + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + let inserted = format!("${skill_name}"); + + let mut new_text = + String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); + new_text.push_str(&text[..start_idx]); + new_text.push_str(&inserted); + new_text.push(' '); + new_text.push_str(&text[end_idx..]); + + self.textarea.set_text(&new_text); + let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); + self.textarea.set_cursor(new_cursor); + } + + /// Handle key event when no popup is visible. + fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + if self.is_empty() { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + match key_event { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } if self.is_empty() => { + self.app_event_tx.send(AppEvent::ExitRequest); + (InputResult::None, true) + } + // ------------------------------------------------------------- + // History navigation (Up / Down) – only when the composer is not + // empty or when the cursor is at the correct position, to avoid + // interfering with normal cursor movement. + // ------------------------------------------------------------- + KeyEvent { + code: KeyCode::Up | KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('p') | KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + if self + .history + .should_handle_navigation(self.textarea.text(), self.textarea.cursor()) + { + let replace_text = match key_event.code { + KeyCode::Up => self.history.navigate_up(&self.app_event_tx), + KeyCode::Down => self.history.navigate_down(&self.app_event_tx), + KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx), + KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx), + _ => unreachable!(), + }; + if let Some(text) = replace_text { + self.set_text_content(text); + return (InputResult::None, true); + } + } + self.handle_input_basic(key_event) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + // If the first line is a bare built-in slash command (no args), + // dispatch it even when the slash popup isn't visible. This preserves + // the workflow: type a prefix ("/di"), press Tab to complete to + // "/diff ", then press Enter to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter should still dispatch the command rather than submit + // literal text. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, rest)) = parse_slash_name(first_line) + && rest.is_empty() + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .find(|(n, _)| *n == name) + { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + // If we're in a paste-like burst capture, treat Enter as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat Enter as paste inside a slash-command context. + let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/'); + if self.paste_burst.is_active() && !in_slash_context { + let now = Instant::now(); + if self.paste_burst.append_newline_if_active(now) { + return (InputResult::None, true); + } + } + // If we have pending placeholder pastes, replace them in the textarea text + // and continue to the normal submission flow to handle slash commands. + if !self.pending_pastes.is_empty() { + let mut text = self.textarea.text().to_string(); + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.textarea.set_text(&text); + self.pending_pastes.clear(); + } + + // During a paste-like burst, treat Enter as a newline instead of submit. + let now = Instant::now(); + if self + .paste_burst + .newline_should_insert_instead_of_submit(now) + && !in_slash_context + { + self.textarea.insert_str("\n"); + self.paste_burst.extend_window(now); + return (InputResult::None, true); + } + let mut text = self.textarea.text().to_string(); + let original_input = text.clone(); + let input_starts_with_space = original_input.starts_with(' '); + self.textarea.set_text(""); + + // Replace all pending pastes in the text + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.pending_pastes.clear(); + + // If there is neither text nor attachments, suppress submission entirely. + let has_attachments = !self.attached_images.is_empty(); + text = text.trim().to_string(); + if let Some((name, _rest)) = parse_slash_name(&text) { + let treat_as_plain_text = input_starts_with_space || name.contains('/'); + if !treat_as_plain_text { + let is_builtin = built_in_slash_commands() + .into_iter() + .any(|(command_name, _)| command_name == name); + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = name + .strip_prefix(&prompt_prefix) + .map(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) + .unwrap_or(false); + if !is_builtin && !is_known_prompt { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, None), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + } + } + + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded; + } + if text.is_empty() && !has_attachments { + return (InputResult::None, true); + } + if !text.is_empty() { + self.history.record_local_submission(&text); + } + // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). + (InputResult::Submitted(text), true) + } + input => self.handle_input_basic(input), + } + } + + fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { + match self.paste_burst.flush_if_due(now) { + FlushResult::Paste(pasted) => { + self.handle_paste(pasted); + true + } + FlushResult::Typed(ch) => { + // Mirror insert_str() behavior so popups stay in sync when a + // pending fast char flushes as normal typed input. + self.textarea.insert_str(ch.to_string().as_str()); + self.sync_popups(); + true + } + FlushResult::None => false, + } + } + + /// Handle generic Input events that modify the textarea content. + fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { + // If we have a buffered non-bracketed paste burst and enough time has + // elapsed since the last char, flush it before handling a new input. + let now = Instant::now(); + self.handle_paste_burst_flush(now); + + if !matches!(input.code, KeyCode::Esc) { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + // If we're capturing a burst and receive Enter, accumulate it instead of inserting. + if matches!(input.code, KeyCode::Enter) + && self.paste_burst.is_active() + && self.paste_burst.append_newline_if_active(now) + { + return (InputResult::None, true); + } + + // Intercept plain Char inputs to optionally accumulate into a burst buffer. + if let KeyEvent { + code: KeyCode::Char(ch), + modifiers, + .. + } = input + { + let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); + if !has_ctrl_or_alt { + // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be + // misclassified by paste heuristics. Flush any active burst buffer and insert + // non-ASCII characters directly. + if !ch.is_ascii() { + return self.handle_non_ascii_char(input); + } + + match self.paste_burst.on_plain_char(ch, now) { + CharDecision::BufferAppend => { + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + CharDecision::BeginBuffer { retro_chars } => { + let cur = self.textarea.cursor(); + let txt = self.textarea.text(); + let safe_cur = Self::clamp_to_char_boundary(txt, cur); + let before = &txt[..safe_cur]; + if let Some(grab) = + self.paste_burst + .decide_begin_buffer(now, before, retro_chars as usize) + { + if !grab.grabbed.is_empty() { + self.textarea.replace_range(grab.start_byte..safe_cur, ""); + } + self.paste_burst.begin_with_retro_grabbed(grab.grabbed, now); + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + // If decide_begin_buffer opted not to start buffering, + // fall through to normal insertion below. + } + CharDecision::BeginBufferFromPending => { + // First char was held; now append the current one. + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + CharDecision::RetainFirstChar => { + // Keep the first fast char pending momentarily. + return (InputResult::None, true); + } + } + } + if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + } + + // For non-char inputs (or after flushing), handle normally. + // Special handling for backspace on placeholders + if let KeyEvent { + code: KeyCode::Backspace, + .. + } = input + && self.try_remove_any_placeholder_at_cursor() + { + return (InputResult::None, true); + } + + // Normal input handling + self.textarea.input(input); + let text_after = self.textarea.text(); + + // Update paste-burst heuristic for plain Char (no Ctrl/Alt) events. + let crossterm::event::KeyEvent { + code, modifiers, .. + } = input; + match code { + KeyCode::Char(_) => { + let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); + if has_ctrl_or_alt { + self.paste_burst.clear_window_after_non_char(); + } + } + KeyCode::Enter => { + // Keep burst window alive (supports blank lines in paste). + } + _ => { + // Other keys: clear burst window (buffer should have been flushed above if needed). + self.paste_burst.clear_window_after_non_char(); + } + } + + // Check if any placeholders were removed and remove their corresponding pending pastes + self.pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + + // Keep attached images in proportion to how many matching placeholders exist in the text. + // This handles duplicate placeholders that share the same visible label. + if !self.attached_images.is_empty() { + let mut needed: HashMap = HashMap::new(); + for img in &self.attached_images { + needed + .entry(img.placeholder.clone()) + .or_insert_with(|| text_after.matches(&img.placeholder).count()); + } + + let mut used: HashMap = HashMap::new(); + let mut kept: Vec = Vec::with_capacity(self.attached_images.len()); + for img in self.attached_images.drain(..) { + let total_needed = *needed.get(&img.placeholder).unwrap_or(&0); + let used_count = used.entry(img.placeholder.clone()).or_insert(0); + if *used_count < total_needed { + kept.push(img); + *used_count += 1; + } + } + self.attached_images = kept; + } + + (InputResult::None, true) + } + + /// Attempts to remove an image or paste placeholder if the cursor is at the end of one. + /// Returns true if a placeholder was removed. + fn try_remove_any_placeholder_at_cursor(&mut self) -> bool { + // Clamp the cursor to a valid char boundary to avoid panics when slicing. + let text = self.textarea.text(); + let p = Self::clamp_to_char_boundary(text, self.textarea.cursor()); + + // Try image placeholders first + let mut out: Option<(usize, String)> = None; + // Detect if the cursor is at the end of any image placeholder. + // If duplicates exist, remove the specific occurrence's mapping. + for (i, img) in self.attached_images.iter().enumerate() { + let ph = &img.placeholder; + if p < ph.len() { + continue; + } + let start = p - ph.len(); + if text.get(start..p) != Some(ph.as_str()) { + continue; + } + + // Count the number of occurrences of `ph` before `start`. + let mut occ_before = 0usize; + let mut search_pos = 0usize; + while search_pos < start { + let segment = match text.get(search_pos..start) { + Some(s) => s, + None => break, + }; + if let Some(found) = segment.find(ph) { + occ_before += 1; + search_pos += found + ph.len(); + } else { + break; + } + } + + // Remove the occ_before-th attached image that shares this placeholder label. + out = if let Some((remove_idx, _)) = self + .attached_images + .iter() + .enumerate() + .filter(|(_, img2)| img2.placeholder == *ph) + .nth(occ_before) + { + Some((remove_idx, ph.clone())) + } else { + Some((i, ph.clone())) + }; + break; + } + if let Some((idx, placeholder)) = out { + self.textarea.replace_range(p - placeholder.len()..p, ""); + self.attached_images.remove(idx); + return true; + } + + // Also handle when the cursor is at the START of an image placeholder. + // let result = 'out: { + let out: Option<(usize, String)> = 'out: { + for (i, img) in self.attached_images.iter().enumerate() { + let ph = &img.placeholder; + if p + ph.len() > text.len() { + continue; + } + if text.get(p..p + ph.len()) != Some(ph.as_str()) { + continue; + } + + // Count occurrences of `ph` before `p`. + let mut occ_before = 0usize; + let mut search_pos = 0usize; + while search_pos < p { + let segment = match text.get(search_pos..p) { + Some(s) => s, + None => break 'out None, + }; + if let Some(found) = segment.find(ph) { + occ_before += 1; + search_pos += found + ph.len(); + } else { + break 'out None; + } + } + + if let Some((remove_idx, _)) = self + .attached_images + .iter() + .enumerate() + .filter(|(_, img2)| img2.placeholder == *ph) + .nth(occ_before) + { + break 'out Some((remove_idx, ph.clone())); + } else { + break 'out Some((i, ph.clone())); + } + } + None + }; + + if let Some((idx, placeholder)) = out { + self.textarea.replace_range(p..p + placeholder.len(), ""); + self.attached_images.remove(idx); + return true; + } + + // Then try pasted-content placeholders + if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| { + if p < ph.len() { + return None; + } + let start = p - ph.len(); + if text.get(start..p) == Some(ph.as_str()) { + Some(ph.clone()) + } else { + None + } + }) { + self.textarea.replace_range(p - placeholder.len()..p, ""); + self.pending_pastes.retain(|(ph, _)| ph != &placeholder); + return true; + } + + // Also handle when the cursor is at the START of a pasted-content placeholder. + if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| { + if p + ph.len() > text.len() { + return None; + } + if text.get(p..p + ph.len()) == Some(ph.as_str()) { + Some(ph.clone()) + } else { + None + } + }) { + self.textarea.replace_range(p..p + placeholder.len(), ""); + self.pending_pastes.retain(|(ph, _)| ph != &placeholder); + return true; + } + + false + } + + fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { + if key_event.kind != KeyEventKind::Press { + return false; + } + + let toggles = matches!(key_event.code, KeyCode::Char('?')) + && !has_ctrl_or_alt(key_event.modifiers) + && self.is_empty(); + + if !toggles { + return false; + } + + let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); + let changed = next != self.footer_mode; + self.footer_mode = next; + changed + } + + fn footer_props(&self) -> FooterProps { + FooterProps { + mode: self.footer_mode(), + esc_backtrack_hint: self.esc_backtrack_hint, + use_shift_enter_hint: self.use_shift_enter_hint, + is_task_running: self.is_task_running, + context_window_percent: self.context_window_percent, + context_window_used_tokens: self.context_window_used_tokens, + } + } + + fn footer_mode(&self) -> FooterMode { + match self.footer_mode { + FooterMode::EscHint => FooterMode::EscHint, + FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, + FooterMode::CtrlCReminder => FooterMode::CtrlCReminder, + FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, + FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly, + other => other, + } + } + + fn custom_footer_height(&self) -> Option { + self.footer_hint_override + .as_ref() + .map(|items| if items.is_empty() { 0 } else { 1 }) + } + + fn sync_popups(&mut self) { + let file_token = Self::current_at_token(&self.textarea); + let skill_token = self.current_skill_token(); + + let allow_command_popup = file_token.is_none() && skill_token.is_none(); + self.sync_command_popup(allow_command_popup); + + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + self.dismissed_skill_popup_token = None; + return; + } + + if let Some(token) = skill_token { + self.sync_skill_popup(token); + return; + } + self.dismissed_skill_popup_token = None; + + if let Some(token) = file_token { + self.sync_file_search_popup(token); + return; + } + + self.dismissed_file_popup_token = None; + if matches!( + self.active_popup, + ActivePopup::File(_) | ActivePopup::Skill(_) + ) { + self.active_popup = ActivePopup::None; + } + } + + /// If the cursor is currently within a slash command on the first line, + /// extract the command name and the rest of the line after it. + /// Returns None if the cursor is outside a slash command. + fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { + if !first_line.starts_with('/') { + return None; + } + + let name_start = 1usize; + let name_end = first_line[name_start..] + .find(char::is_whitespace) + .map(|idx| name_start + idx) + .unwrap_or_else(|| first_line.len()); + + if cursor > name_end { + return None; + } + + let name = &first_line[name_start..name_end]; + let rest_start = first_line[name_end..] + .find(|c: char| !c.is_whitespace()) + .map(|idx| name_end + idx) + .unwrap_or(name_end); + let rest = &first_line[rest_start..]; + + Some((name, rest)) + } + + /// Heuristic for whether the typed slash command looks like a valid + /// prefix for any known command (built-in or custom prompt). + /// Empty names only count when there is no extra content after the '/'. + fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { + if name.is_empty() { + return rest_after_name.is_empty(); + } + + let builtin_match = built_in_slash_commands() + .into_iter() + .any(|(cmd_name, _)| cmd_name.starts_with(name)); + + if builtin_match { + return true; + } + + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + self.custom_prompts + .iter() + .any(|p| format!("{prompt_prefix}{}", p.name).starts_with(name)) + } + + /// Synchronize `self.command_popup` with the current text in the + /// textarea. This must be called after every modification that can change + /// the text so the popup is shown/updated/hidden as appropriate. + fn sync_command_popup(&mut self, allow: bool) { + if !allow { + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.active_popup = ActivePopup::None; + } + return; + } + // Determine whether the caret is inside the initial '/name' token on the first line. + let text = self.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let first_line = &text[..first_line_end]; + let cursor = self.textarea.cursor(); + let caret_on_first_line = cursor <= first_line_end; + + let is_editing_slash_command_name = caret_on_first_line + && Self::slash_command_under_cursor(first_line, cursor) + .is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest)); + + // If the cursor is currently positioned within an `@token`, prefer the + // file-search popup over the slash popup so users can insert a file path + // as an argument to the command (e.g., "/review @docs/..."). + if Self::current_at_token(&self.textarea).is_some() { + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.active_popup = ActivePopup::None; + } + return; + } + match &mut self.active_popup { + ActivePopup::Command(popup) => { + if is_editing_slash_command_name { + popup.on_composer_text_change(first_line.to_string()); + } else { + self.active_popup = ActivePopup::None; + } + } + _ => { + if is_editing_slash_command_name { + let skills_enabled = self.skills_enabled(); + let mut command_popup = + CommandPopup::new(self.custom_prompts.clone(), skills_enabled); + command_popup.on_composer_text_change(first_line.to_string()); + self.active_popup = ActivePopup::Command(command_popup); + } + } + } + } + + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { + self.custom_prompts = prompts.clone(); + if let ActivePopup::Command(popup) = &mut self.active_popup { + popup.set_prompts(prompts); + } + } + + /// Synchronize `self.file_search_popup` with the current text in the textarea. + /// Note this is only called when self.active_popup is NOT Command. + fn sync_file_search_popup(&mut self, query: String) { + // If user dismissed popup for this exact query, don't reopen until text changes. + if self.dismissed_file_popup_token.as_ref() == Some(&query) { + return; + } + + if !query.is_empty() { + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); + } + + match &mut self.active_popup { + ActivePopup::File(popup) => { + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } + } + _ => { + let mut popup = FileSearchPopup::new(); + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } + self.active_popup = ActivePopup::File(popup); + } + } + + self.current_file_query = Some(query); + self.dismissed_file_popup_token = None; + } + + fn sync_skill_popup(&mut self, query: String) { + if self.dismissed_skill_popup_token.as_ref() == Some(&query) { + return; + } + + let skills = match self.skills.as_ref() { + Some(skills) if !skills.is_empty() => skills.clone(), + _ => { + self.active_popup = ActivePopup::None; + return; + } + }; + + match &mut self.active_popup { + ActivePopup::Skill(popup) => { + popup.set_query(&query); + popup.set_skills(skills); + } + _ => { + let mut popup = SkillPopup::new(skills); + popup.set_query(&query); + self.active_popup = ActivePopup::Skill(popup); + } + } + } + + fn set_has_focus(&mut self, has_focus: bool) { + self.has_focus = has_focus; + } + + pub fn set_task_running(&mut self, running: bool) { + self.is_task_running = running; + } + + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { + if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + { + return; + } + self.context_window_percent = percent; + self.context_window_used_tokens = used_tokens; + } + + pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { + self.esc_backtrack_hint = show; + if show { + self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + } +} + +impl Renderable for ChatComposer { + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let [_, textarea_rect, _] = self.layout_areas(area); + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } + + fn desired_height(&self, width: u16) -> u16 { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let footer_total_height = footer_hint_height + footer_spacing; + const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; + self.textarea + .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) + + 2 + + match &self.active_popup { + ActivePopup::None => footer_total_height, + ActivePopup::Command(c) => c.calculate_required_height(width), + ActivePopup::File(c) => c.calculate_required_height(), + ActivePopup::Skill(c) => c.calculate_required_height(width), + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); + match &self.active_popup { + ActivePopup::Command(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::File(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::Skill(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::None => { + let footer_props = self.footer_props(); + let custom_height = self.custom_footer_height(); + let footer_hint_height = + custom_height.unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = Layout::vertical([ + Constraint::Length(footer_spacing), + Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + }; + if let Some(items) = self.footer_hint_override.as_ref() { + if !items.is_empty() { + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(Span::styled(key.clone(), Style::default().bold())); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + let mut custom_rect = hint_rect; + if custom_rect.width > 2 { + custom_rect.x += 2; + custom_rect.width = custom_rect.width.saturating_sub(2); + } + Line::from(spans).render_ref(custom_rect, buf); + } + } else { + render_footer(hint_rect, buf, footer_props); + } + } + } + let style = user_message_style(); + Block::default().style(style).render_ref(composer_rect, buf); + if !textarea_rect.is_empty() { + buf.set_span( + textarea_rect.x - LIVE_PREFIX_COLS, + textarea_rect.y, + &"›".bold(), + textarea_rect.width, + ); + } + + 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(); + Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); + } + } +} + +fn prompt_selection_action( + prompt: &CustomPrompt, + first_line: &str, + mode: PromptSelectionMode, +) -> PromptSelectionAction { + let named_args = prompt_argument_names(&prompt.content); + let has_numeric = prompt_has_numeric_placeholders(&prompt.content); + + match mode { + PromptSelectionMode::Completion => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + return PromptSelectionAction::Insert { text, cursor: None }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name); + PromptSelectionAction::Insert { text, cursor: None } + } + PromptSelectionMode::Submit => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { + return PromptSelectionAction::Submit { text: expanded }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + return PromptSelectionAction::Insert { text, cursor: None }; + } + PromptSelectionAction::Submit { + text: prompt.content.clone(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use image::ImageBuffer; + use image::Rgba; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + use tempfile::tempdir; + + use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; + use crate::bottom_pane::ChatComposer; + use crate::bottom_pane::InputResult; + use crate::bottom_pane::chat_composer::AttachedImage; + use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; + use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; + use crate::bottom_pane::textarea::TextArea; + use tokio::sync::mpsc::unbounded_channel; + + #[test] + fn footer_hint_row_is_separated_from_composer() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let row_to_string = |y: u16| { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + row + }; + + let mut hint_row: Option<(u16, String)> = None; + for y in 0..area.height { + let row = row_to_string(y); + if row.contains("? for shortcuts") { + hint_row = Some((y, row)); + break; + } + } + + let (hint_row_idx, hint_row_contents) = + hint_row.expect("expected footer hint row to be rendered"); + assert_eq!( + hint_row_idx, + area.height - 1, + "hint row should occupy the bottom line: {hint_row_contents:?}", + ); + + assert!( + hint_row_idx > 0, + "expected a spacing row above the footer hints", + ); + + let spacing_row = row_to_string(hint_row_idx - 1); + assert_eq!( + spacing_row.trim(), + "", + "expected blank spacing row above hints but saw: {spacing_row:?}", + ); + } + + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) + where + F: FnOnce(&mut ChatComposer), + { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let width = 100; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + enhanced_keys_supported, + "Ask Codex to do anything".to_string(), + false, + ); + setup(&mut composer); + let footer_props = composer.footer_props(); + let footer_lines = footer_height(footer_props); + let footer_spacing = ChatComposer::footer_spacing(footer_lines); + let height = footer_lines + footer_spacing + 8; + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap(); + insta::assert_snapshot!(name, terminal.backend()); + } + + #[test] + fn footer_mode_snapshots() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| { + composer.set_esc_backtrack_hint(true); + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + }); + + snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| { + composer.set_ctrl_c_quit_hint(true, true); + }); + + snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| { + composer.set_task_running(true); + composer.set_ctrl_c_quit_hint(true, true); + }); + + snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| { + composer.set_ctrl_c_quit_hint(true, true); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }); + + snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }); + + snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| { + composer.set_esc_backtrack_hint(true); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }); + + snapshot_composer_state( + "footer_mode_overlay_then_external_esc_hint", + true, + |composer| { + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + composer.set_esc_backtrack_hint(true); + }, + ); + + snapshot_composer_state("footer_mode_hidden_while_typing", true, |composer| { + type_chars_humanlike(composer, &['h']); + }); + } + + #[test] + fn esc_hint_stays_hidden_with_draft_content() { + 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, + true, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['d']); + + assert!(!composer.is_empty()); + assert_eq!(composer.current_text(), "d"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert!(matches!(composer.active_popup, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert!(!composer.esc_backtrack_hint); + } + + #[test] + fn clear_for_ctrl_c_records_cleared_draft() { + 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("draft text".to_string()); + assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); + assert!(composer.is_empty()); + + assert_eq!( + composer.history.navigate_up(&composer.app_event_tx), + Some("draft text".to_string()) + ); + } + + #[test] + fn question_mark_only_toggles_on_first_char() { + 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, + ); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "toggling overlay should request redraw"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + + // Toggle back to prompt mode so subsequent typing captures characters. + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + + type_chars_humanlike(&mut composer, &['h']); + assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "typing should still mark the view dirty"); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + assert_eq!(composer.textarea.text(), "h?"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); + } + + #[test] + fn shortcut_overlay_persists_while_task_running() { + 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, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + + composer.set_task_running(true); + + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); + } + + #[test] + fn test_current_at_token_basic_cases() { + let test_cases = vec![ + // Valid @ tokens + ("@hello", 3, Some("hello".to_string()), "Basic ASCII token"), + ( + "@file.txt", + 4, + Some("file.txt".to_string()), + "ASCII with extension", + ), + ( + "hello @world test", + 8, + Some("world".to_string()), + "ASCII token in middle", + ), + ( + "@test123", + 5, + Some("test123".to_string()), + "ASCII with numbers", + ), + // Unicode examples + ("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"), + ( + "@testЙЦУ.rs", + 8, + Some("testЙЦУ.rs".to_string()), + "Mixed ASCII and Cyrillic", + ), + ("@诶", 2, Some("诶".to_string()), "Chinese character"), + ("@👍", 2, Some("👍".to_string()), "Emoji token"), + // Invalid cases (should return None) + ("hello", 2, None, "No @ symbol"), + ( + "@", + 1, + Some("".to_string()), + "Only @ symbol triggers empty query", + ), + ("@ hello", 2, None, "@ followed by space"), + ("test @ world", 6, None, "@ with spaces around"), + ]; + + for (input, cursor_pos, expected, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, expected, + "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" + ); + } + } + + #[test] + fn test_current_at_token_cursor_positions() { + let test_cases = vec![ + // Different cursor positions within a token + ("@test", 0, Some("test".to_string()), "Cursor at @"), + ("@test", 1, Some("test".to_string()), "Cursor after @"), + ("@test", 5, Some("test".to_string()), "Cursor at end"), + // Multiple tokens - cursor determines which token + ("@file1 @file2", 0, Some("file1".to_string()), "First token"), + ( + "@file1 @file2", + 8, + Some("file2".to_string()), + "Second token", + ), + // Edge cases + ("@", 0, Some("".to_string()), "Only @ symbol"), + ("@a", 2, Some("a".to_string()), "Single character after @"), + ("", 0, None, "Empty input"), + ]; + + for (input, cursor_pos, expected, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, expected, + "Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}", + ); + } + } + + #[test] + fn test_current_at_token_whitespace_boundaries() { + let test_cases = vec![ + // Space boundaries + ( + "aaa@aaa", + 4, + None, + "Connected @ token - no completion by design", + ), + ( + "aaa @aaa", + 5, + Some("aaa".to_string()), + "@ token after space", + ), + ( + "test @file.txt", + 7, + Some("file.txt".to_string()), + "@ token after space", + ), + // Full-width space boundaries + ( + "test @İstanbul", + 8, + Some("İstanbul".to_string()), + "@ token after full-width space", + ), + ( + "@ЙЦУ @诶", + 10, + Some("诶".to_string()), + "Full-width space between Unicode tokens", + ), + // Tab and newline boundaries + ( + "test\t@file", + 6, + Some("file".to_string()), + "@ token after tab", + ), + ]; + + for (input, cursor_pos, expected, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, expected, + "Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}", + ); + } + } + + #[test] + fn ascii_prefix_survives_non_ascii_followup() { + 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, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert!(composer.is_in_paste_burst()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "1あ"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn handle_paste_small_inserts_text() { + 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, + ); + + let needs_redraw = composer.handle_paste("hello".to_string()); + assert!(needs_redraw); + assert_eq!(composer.textarea.text(), "hello"); + assert!(composer.pending_pastes.is_empty()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "hello"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn empty_enter_returns_none() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Ensure composer is empty and press Enter. + assert!(composer.textarea.text().is_empty()); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::None => {} + other => panic!("expected None for empty enter, got: {other:?}"), + } + } + + #[test] + fn handle_paste_large_uses_placeholder_and_replaces_on_submit() { + use crossterm::event::KeyCode; + 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, + ); + + let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); + let needs_redraw = composer.handle_paste(large.clone()); + assert!(needs_redraw); + let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); + assert_eq!(composer.textarea.text(), placeholder); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, placeholder); + assert_eq!(composer.pending_pastes[0].1, large); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, large), + _ => panic!("expected Submitted"), + } + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn edit_clears_pending_paste() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + 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.handle_paste(large); + assert_eq!(composer.pending_pastes.len(), 1); + + // Any edit that removes the placeholder should clear pending_paste + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn ui_snapshots() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + + let test_cases = vec![ + ("empty", None), + ("small", Some("short".to_string())), + ("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))), + ("multiple_pastes", None), + ("backspace_after_pastes", None), + ]; + + for (name, input) in test_cases { + // Create a fresh composer for each test case + let mut composer = ChatComposer::new( + true, + sender.clone(), + false, + "Ask Codex to do anything".to_string(), + false, + ); + + if let Some(text) = input { + composer.handle_paste(text); + } else if name == "multiple_pastes" { + // First large paste + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3)); + // Second large paste + composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7)); + // Small paste + composer.handle_paste(" another short paste".to_string()); + } else if name == "backspace_after_pastes" { + // Three large pastes + composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2)); + composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); + composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); + // Move cursor to end and press backspace + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}")); + + insta::assert_snapshot!(name, terminal.backend()); + } + } + + #[test] + fn slash_popup_model_first_for_mo_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type "/mo" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + let mut terminal = match Terminal::new(TestBackend::new(60, 5)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); + + // Visual snapshot should show the slash popup with /model as the first entry. + insta::assert_snapshot!("slash_popup_mo", terminal.backend()); + } + + #[test] + fn slash_popup_model_first_for_mo_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "model") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/mo'") + } + None => panic!("no selected command for '/mo'"), + }, + _ => panic!("slash popup not active after typing '/mo'"), + } + } + + #[test] + fn slash_popup_resume_for_res_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type "/res" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + // Snapshot should show /resume as the first entry for /res. + insta::assert_snapshot!("slash_popup_res", terminal.backend()); + } + + #[test] + fn slash_popup_resume_for_res_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "resume") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/res'") + } + None => panic!("no selected command for '/res'"), + }, + _ => panic!("slash popup not active after typing '/res'"), + } + } + + // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer + fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + for &ch in chars { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + } + } + + #[test] + fn slash_init_dispatches_command_and_does_not_submit_literal_text() { + 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, + ); + + // Type the slash command. + type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']); + + // Press Enter to dispatch the selected command. + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // When a slash command is dispatched, the composer should return a + // Command result (not submit literal text) and clear its textarea. + match result { + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "init"); + } + InputResult::Submitted(text) => { + panic!("expected command dispatch, but composer submitted literal text: {text}") + } + InputResult::None => panic!("expected Command result for '/init'"), + } + assert!(composer.textarea.is_empty(), "composer should be cleared"); + } + + #[test] + fn extract_args_supports_quoted_paths_single_arg() { + let args = extract_positional_args_for_prompt_line( + "/prompts:review \"docs/My File.md\"", + "review", + ); + assert_eq!(args, vec!["docs/My File.md".to_string()]); + } + + #[test] + fn extract_args_supports_mixed_quoted_and_unquoted() { + let args = + extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd"); + assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); + } + + #[test] + fn slash_tab_completion_moves_cursor_to_end() { + 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, + ); + + type_chars_humanlike(&mut composer, &['/', 'c']); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!(composer.textarea.text(), "/compact "); + assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + } + + #[test] + fn slash_tab_then_enter_dispatches_builtin_command() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type a prefix and complete with Tab, which inserts a trailing space + // and moves the cursor beyond the '/name' token (hides the popup). + type_chars_humanlike(&mut composer, &['/', 'd', 'i']); + let (_res, _redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "/diff "); + + // Press Enter: should dispatch the command, not submit literal text. + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), + InputResult::Submitted(text) => { + panic!("expected command dispatch after Tab completion, got literal submit: {text}") + } + InputResult::None => panic!("expected Command result for '/diff'"), + } + assert!(composer.textarea.is_empty()); + } + + #[test] + fn slash_mention_dispatches_command_and_inserts_at() { + 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, + ); + + type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "mention"); + } + InputResult::Submitted(text) => { + panic!("expected command dispatch, but composer submitted literal text: {text}") + } + InputResult::None => panic!("expected Command result for '/mention'"), + } + assert!(composer.textarea.is_empty(), "composer should be cleared"); + composer.insert_str("@"); + assert_eq!(composer.textarea.text(), "@"); + } + + #[test] + fn test_multiple_pastes_submission() { + 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, + ); + + // Define test cases: (paste content, is_large) + let test_cases = [ + ("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true), + (" and ".to_string(), false), + ("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true), + ]; + + // Expected states after each paste + let mut expected_text = String::new(); + let mut expected_pending_count = 0; + + // Apply all pastes and build expected state + let states: Vec<_> = test_cases + .iter() + .map(|(content, is_large)| { + composer.handle_paste(content.clone()); + if *is_large { + let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); + expected_text.push_str(&placeholder); + expected_pending_count += 1; + } else { + expected_text.push_str(content); + } + (expected_text.clone(), expected_pending_count) + }) + .collect(); + + // Verify all intermediate states were correct + assert_eq!( + states, + vec![ + ( + format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()), + 1 + ), + ( + format!( + "[Pasted Content {} chars] and ", + test_cases[0].0.chars().count() + ), + 1 + ), + ( + format!( + "[Pasted Content {} chars] and [Pasted Content {} chars]", + test_cases[0].0.chars().count(), + test_cases[2].0.chars().count() + ), + 2 + ), + ] + ); + + // Submit and verify final expansion + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + if let InputResult::Submitted(text) = result { + assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); + } else { + panic!("expected Submitted"); + } + } + + #[test] + fn test_placeholder_deletion() { + 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, + ); + + // Define test cases: (content, is_large) + let test_cases = [ + ("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true), + (" and ".to_string(), false), + ("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true), + ]; + + // Apply all pastes + let mut current_pos = 0; + let states: Vec<_> = test_cases + .iter() + .map(|(content, is_large)| { + composer.handle_paste(content.clone()); + if *is_large { + let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); + current_pos += placeholder.len(); + } else { + current_pos += content.len(); + } + ( + composer.textarea.text().to_string(), + composer.pending_pastes.len(), + current_pos, + ) + }) + .collect(); + + // Delete placeholders one by one and collect states + let mut deletion_states = vec![]; + + // First deletion + composer.textarea.set_cursor(states[0].2); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + deletion_states.push(( + composer.textarea.text().to_string(), + composer.pending_pastes.len(), + )); + + // Second deletion + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + deletion_states.push(( + composer.textarea.text().to_string(), + composer.pending_pastes.len(), + )); + + // Verify all states + assert_eq!( + deletion_states, + vec![ + (" and [Pasted Content 1006 chars]".to_string(), 1), + (" and ".to_string(), 0), + ] + ); + } + + #[test] + fn deleting_duplicate_length_pastes_removes_only_target() { + 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, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let placeholder_base = format!("[Pasted Content {} chars]", paste.chars().count()); + let placeholder_second = format!("{placeholder_base} #2"); + + composer.handle_paste(paste.clone()); + composer.handle_paste(paste.clone()); + assert_eq!( + composer.textarea.text(), + format!("{placeholder_base}{placeholder_second}") + ); + assert_eq!(composer.pending_pastes.len(), 2); + + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!(composer.textarea.text(), placeholder_base); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, placeholder_base); + assert_eq!(composer.pending_pastes[0].1, paste); + } + + #[test] + fn large_paste_numbering_does_not_reuse_after_deletion() { + 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, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", paste.chars().count()); + let second = format!("{base} #2"); + let third = format!("{base} #3"); + + composer.handle_paste(paste.clone()); + composer.handle_paste(paste.clone()); + assert_eq!(composer.textarea.text(), format!("{base}{second}")); + + composer.textarea.set_cursor(base.len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), second); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, second); + + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_paste(paste); + + assert_eq!(composer.textarea.text(), format!("{second}{third}")); + assert_eq!(composer.pending_pastes.len(), 2); + assert_eq!(composer.pending_pastes[0].0, second); + assert_eq!(composer.pending_pastes[1].0, third); + } + + #[test] + fn test_partial_placeholder_deletion() { + 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, + ); + + // Define test cases: (cursor_position_from_end, expected_pending_count) + let test_cases = [ + 5, // Delete from middle - should clear tracking + 0, // Delete from end - should clear tracking + ]; + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let placeholder = format!("[Pasted Content {} chars]", paste.chars().count()); + + let states: Vec<_> = test_cases + .into_iter() + .map(|pos_from_end| { + composer.handle_paste(paste.clone()); + composer + .textarea + .set_cursor(placeholder.len() - pos_from_end); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + let result = ( + composer.textarea.text().contains(&placeholder), + composer.pending_pastes.len(), + ); + composer.textarea.set_text(""); + result + }) + .collect(); + + assert_eq!( + states, + vec![ + (false, 0), // After deleting from middle + (false, 0), // After deleting from end + ] + ); + } + + // --- Image attachment tests --- + #[test] + fn attach_image_and_submit_includes_image_paths() { + 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, + ); + let path = PathBuf::from("/tmp/image1.png"); + composer.attach_image(path.clone(), 32, 16, "PNG"); + composer.handle_paste(" hi".into()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "[image1.png 32x16] hi"), + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn attach_image_without_text_submits_empty_text_and_images() { + 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, + ); + let path = PathBuf::from("/tmp/image2.png"); + composer.attach_image(path.clone(), 10, 5, "PNG"); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "[image2.png 10x5]"), + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(imgs.len(), 1); + assert_eq!(imgs[0], path); + assert!(composer.attached_images.is_empty()); + } + + #[test] + fn image_placeholder_backspace_behaves_like_text_placeholder() { + 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, + ); + let path = PathBuf::from("/tmp/image3.png"); + composer.attach_image(path.clone(), 20, 10, "PNG"); + let placeholder = composer.attached_images[0].placeholder.clone(); + + // Case 1: backspace at end + composer.textarea.move_cursor_to_end_of_line(false); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(!composer.textarea.text().contains(&placeholder)); + assert!(composer.attached_images.is_empty()); + + // Re-add and test backspace in middle: should break the placeholder string + // and drop the image mapping (same as text placeholder behavior). + composer.attach_image(path, 20, 10, "PNG"); + let placeholder2 = composer.attached_images[0].placeholder.clone(); + // Move cursor to roughly middle of placeholder + if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { + let mid_pos = start_pos + (placeholder2.len() / 2); + composer.textarea.set_cursor(mid_pos); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(!composer.textarea.text().contains(&placeholder2)); + assert!(composer.attached_images.is_empty()); + } else { + panic!("Placeholder not found in textarea"); + } + } + + #[test] + fn backspace_with_multibyte_text_before_placeholder_does_not_panic() { + 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, + ); + + // Insert an image placeholder at the start + let path = PathBuf::from("/tmp/image_multibyte.png"); + composer.attach_image(path, 10, 5, "PNG"); + // Add multibyte text after the placeholder + composer.textarea.insert_str("日本語"); + + // Cursor is at end; pressing backspace should delete the last character + // without panicking and leave the placeholder intact. + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!(composer.attached_images.len(), 1); + assert!( + composer + .textarea + .text() + .starts_with("[image_multibyte.png 10x5]") + ); + } + + #[test] + fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() { + 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, + ); + + let path1 = PathBuf::from("/tmp/image_dup1.png"); + let path2 = PathBuf::from("/tmp/image_dup2.png"); + + composer.attach_image(path1, 10, 5, "PNG"); + // separate placeholders with a space for clarity + composer.handle_paste(" ".into()); + composer.attach_image(path2.clone(), 10, 5, "PNG"); + + let placeholder1 = composer.attached_images[0].placeholder.clone(); + let placeholder2 = composer.attached_images[1].placeholder.clone(); + let text = composer.textarea.text().to_string(); + let start1 = text.find(&placeholder1).expect("first placeholder present"); + let end1 = start1 + placeholder1.len(); + composer.textarea.set_cursor(end1); + + // Backspace should delete the first placeholder and its mapping. + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + let new_text = composer.textarea.text().to_string(); + assert_eq!( + 0, + new_text.matches(&placeholder1).count(), + "first placeholder removed" + ); + assert_eq!( + 1, + new_text.matches(&placeholder2).count(), + "second placeholder remains" + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: "[image_dup2.png 10x5]".to_string() + }], + composer.attached_images, + "one image mapping remains" + ); + } + + #[test] + fn pasting_filepath_attaches_image() { + let tmp = tempdir().expect("create TempDir"); + let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png"); + let img: ImageBuffer, Vec> = + ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255])); + img.save(&tmp_path).expect("failed to write temp png"); + + 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, + ); + + let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); + assert!(needs_redraw); + assert!( + composer + .textarea + .text() + .starts_with("[codex_tui_test_paste_image.png 3x2] ") + ); + + let imgs = composer.take_recent_submission_images(); + assert_eq!(imgs, vec![tmp_path]); + } + + #[test] + fn selecting_custom_prompt_without_args_submits_content() { + let prompt_text = "Hello from saved prompt"; + + 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, + ); + + // Inject prompts as if received via event. + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', + ], + ); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn custom_prompt_submission_expands_arguments() { + 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_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Review Alice changes on main".to_string()), + result + ); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn custom_prompt_submission_accepts_quoted_values() { + 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_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Pair $USER with $BRANCH".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Pair Alice Smith with dev-main".to_string()), + result + ); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn custom_prompt_with_large_paste_expands_correctly() { + 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, + ); + + // Create a custom prompt with positional args (no named args like $USER) + composer.set_custom_prompts(vec![CustomPrompt { + name: "code-review".to_string(), + path: "/tmp/code-review.md".to_string().into(), + content: "Please review the following code:\n\n$1".to_string(), + description: None, + argument_hint: None, + }]); + + // Type the slash command + let command_text = "/prompts:code-review "; + composer.textarea.set_text(command_text); + composer.textarea.set_cursor(command_text.len()); + + // Paste large content (>3000 chars) to trigger placeholder + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3000); + composer.handle_paste(large_content.clone()); + + // Verify placeholder was created + let placeholder = format!("[Pasted Content {} chars]", large_content.chars().count()); + assert_eq!( + composer.textarea.text(), + format!("/prompts:code-review {}", placeholder) + ); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, placeholder); + assert_eq!(composer.pending_pastes[0].1, large_content); + + // Submit by pressing Enter + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Verify the custom prompt was expanded with the large content as positional arg + match result { + InputResult::Submitted(text) => { + // The prompt should be expanded, with the large content replacing $1 + assert_eq!( + text, + format!("Please review the following code:\n\n{}", large_content), + "Expected prompt expansion with large content as $1" + ); + } + _ => panic!("expected Submitted, got: {result:?}"), + } + assert!(composer.textarea.is_empty()); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn slash_path_input_submits_without_command_error() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut 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 + .textarea + .set_text("/Users/example/project/src/main.rs"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + if let InputResult::Submitted(text) = result { + assert_eq!(text, "/Users/example/project/src/main.rs"); + } else { + panic!("expected Submitted"); + } + assert!(composer.textarea.is_empty()); + match rx.try_recv() { + Ok(event) => panic!("unexpected event: {event:?}"), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + Err(err) => panic!("unexpected channel state: {err:?}"), + } + } + + #[test] + fn slash_with_leading_space_submits_as_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut 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.textarea.set_text(" /this-looks-like-a-command"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + if let InputResult::Submitted(text) = result { + assert_eq!(text, "/this-looks-like-a-command"); + } else { + panic!("expected Submitted"); + } + assert!(composer.textarea.is_empty()); + match rx.try_recv() { + Ok(event) => panic!("unexpected event: {event:?}"), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + Err(err) => panic!("unexpected channel state: {err:?}"), + } + } + + #[test] + fn custom_prompt_invalid_args_reports_error() { + let (tx, mut 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_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text("/prompts:my-prompt USER=Alice stray"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!( + "/prompts:my-prompt USER=Alice stray", + composer.textarea.text() + ); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains("expected key=value")); + found_error = true; + break; + } + } + assert!(found_error, "expected error history cell to be sent"); + } + + #[test] + fn custom_prompt_missing_required_args_reports_error() { + let (tx, mut 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_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]); + + // Provide only one of the required args + composer.textarea.set_text("/prompts:my-prompt USER=Alice"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!("/prompts:my-prompt USER=Alice", composer.textarea.text()); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.to_lowercase().contains("missing required args")); + assert!(message.contains("BRANCH")); + found_error = true; + break; + } + } + assert!( + found_error, + "expected missing args error history cell to be sent" + ); + } + + #[test] + fn selecting_custom_prompt_with_args_expands_placeholders() { + // Support $1..$9 and $ARGUMENTS in prompt content. + let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n"; + + 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_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + // Type the slash command with two args and hit Enter to submit. + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); + assert_eq!(InputResult::Submitted(expected), result); + } + + #[test] + fn numeric_prompt_positional_args_does_not_error() { + // Ensure that a prompt with only numeric placeholders does not trigger + // key=value parsing errors when given positional arguments. + 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_custom_prompts(vec![CustomPrompt { + name: "elegant".to_string(), + path: "/tmp/elegant.md".to_string().into(), + content: "Echo: $ARGUMENTS".to_string(), + description: None, + argument_hint: None, + }]); + + // Type positional args; should submit with numeric expansion, no errors. + composer.textarea.set_text("/prompts:elegant hi"); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn selecting_custom_prompt_with_no_args_inserts_template() { + let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]"; + + 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_custom_prompts(vec![CustomPrompt { + name: "p".to_string(), + path: "/tmp/p.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &['/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p'], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // With no args typed, selecting the prompt inserts the command template + // and does not submit immediately. + assert_eq!(InputResult::None, result); + assert_eq!("/prompts:p ", composer.textarea.text()); + } + + #[test] + fn selecting_custom_prompt_preserves_literal_dollar_dollar() { + // '$$' should remain untouched. + let prompt_text = "Cost: $$ and first: $1"; + + 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_custom_prompts(vec![CustomPrompt { + name: "price".to_string(), + path: "/tmp/price.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Cost: $$ and first: x".to_string()), + result + ); + } + + #[test] + fn selecting_custom_prompt_reuses_cached_arguments_join() { + let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS"; + + 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_custom_prompts(vec![CustomPrompt { + name: "repeat".to_string(), + path: "/tmp/repeat.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'r', 'e', 'p', 'e', 'a', 't', ' ', + 'o', 'n', 'e', ' ', 't', 'w', 'o', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = "First: one two\nSecond: one two".to_string(); + assert_eq!(InputResult::Submitted(expected), result); + } + + #[test] + fn burst_paste_fast_small_buffers_and_flushes_on_stop() { + 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, + ); + + let count = 32; + for _ in 0..count { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert!( + composer.is_in_paste_burst(), + "expected active paste burst during fast typing" + ); + assert!( + composer.textarea.text().is_empty(), + "text should not appear during burst" + ); + } + + assert!( + composer.textarea.text().is_empty(), + "text should remain empty until flush" + ); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + assert!(flushed, "expected buffered text to flush after stop"); + assert_eq!(composer.textarea.text(), "a".repeat(count)); + assert!( + composer.pending_pastes.is_empty(), + "no placeholder for small burst" + ); + } + + #[test] + fn burst_paste_fast_large_inserts_placeholder_on_flush() { + 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, + ); + + let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder + for _ in 0..count { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + } + + // Nothing should appear until we stop and flush + assert!(composer.textarea.text().is_empty()); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + assert!(flushed, "expected flush after stopping fast input"); + + let expected_placeholder = format!("[Pasted Content {count} chars]"); + assert_eq!(composer.textarea.text(), expected_placeholder); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, expected_placeholder); + assert_eq!(composer.pending_pastes[0].1.len(), count); + assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x')); + } + + #[test] + fn humanlike_typing_1000_chars_appears_live_no_placeholder() { + 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, + ); + + let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config + let chars: Vec = vec!['z'; count]; + type_chars_humanlike(&mut composer, &chars); + + assert_eq!(composer.textarea.text(), "z".repeat(count)); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn slash_popup_not_activated_for_slash_space_text_history_like_input() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use tokio::sync::mpsc::unbounded_channel; + + 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, + ); + + // Simulate history-like content: "/ test" + composer.set_text_content("/ test".to_string()); + + // After set_text_content -> sync_popups is called; popup should NOT be Command. + assert!( + matches!(composer.active_popup, ActivePopup::None), + "expected no slash popup for '/ test'" + ); + + // Up should be handled by history navigation path, not slash popup handler. + let (result, _redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + } + + #[test] + fn slash_popup_activated_for_bare_slash_and_valid_prefixes() { + // use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use tokio::sync::mpsc::unbounded_channel; + + 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, + ); + + // Case 1: bare "/" + composer.set_text_content("/".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::Command(_)), + "bare '/' should activate slash popup" + ); + + // Case 2: valid prefix "/re" (matches /review, /resume, etc.) + composer.set_text_content("/re".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::Command(_)), + "'/re' should activate slash popup via prefix match" + ); + + // Case 3: invalid prefix "/zzz" – still allowed to open popup if it + // matches no built-in command, our current logic will *not* open popup. + // Verify that explicitly. + composer.set_text_content("/zzz".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::None), + "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs new file mode 100644 index 00000000000..991283a5663 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs @@ -0,0 +1,300 @@ +use std::collections::HashMap; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use codex_core::protocol::Op; + +/// State machine that manages shell-style history navigation (Up/Down) inside +/// the chat composer. This struct is intentionally decoupled from the +/// rendering widget so the logic remains isolated and easier to test. +pub(crate) struct ChatComposerHistory { + /// Identifier of the history log as reported by `SessionConfiguredEvent`. + history_log_id: Option, + /// Number of entries already present in the persistent cross-session + /// history file when the session started. + history_entry_count: usize, + + /// Messages submitted by the user *during this UI session* (newest at END). + local_history: Vec, + + /// Cache of persistent history entries fetched on-demand. + fetched_history: HashMap, + + /// Current cursor within the combined (persistent + local) history. `None` + /// indicates the user is *not* currently browsing history. + history_cursor: Option, + + /// The text that was last inserted into the composer as a result of + /// history navigation. Used to decide if further Up/Down presses should be + /// treated as navigation versus normal cursor movement. + last_history_text: Option, +} + +impl ChatComposerHistory { + pub fn new() -> Self { + Self { + history_log_id: None, + history_entry_count: 0, + local_history: Vec::new(), + fetched_history: HashMap::new(), + history_cursor: None, + last_history_text: None, + } + } + + /// Update metadata when a new session is configured. + pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) { + self.history_log_id = Some(log_id); + self.history_entry_count = entry_count; + self.fetched_history.clear(); + self.local_history.clear(); + self.history_cursor = None; + self.last_history_text = None; + } + + /// Record a message submitted by the user in the current session so it can + /// be recalled later. + pub fn record_local_submission(&mut self, text: &str) { + if text.is_empty() { + return; + } + + self.history_cursor = None; + self.last_history_text = None; + + // Avoid inserting a duplicate if identical to the previous entry. + if self.local_history.last().is_some_and(|prev| prev == text) { + return; + } + + self.local_history.push(text.to_string()); + } + + /// Reset navigation tracking so the next Up key resumes from the latest entry. + pub fn reset_navigation(&mut self) { + self.history_cursor = None; + self.last_history_text = None; + } + + /// Should Up/Down key presses be interpreted as history navigation given + /// the current content and cursor position of `textarea`? + pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { + if self.history_entry_count == 0 && self.local_history.is_empty() { + return false; + } + + if text.is_empty() { + return true; + } + + // Textarea is not empty – only navigate when cursor is at start and + // text matches last recalled history entry so regular editing is not + // hijacked. + if cursor != 0 { + return false; + } + + matches!(&self.last_history_text, Some(prev) if prev == text) + } + + /// Handle . Returns true when the key was consumed and the caller + /// should request a redraw. + pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { + let total_entries = self.history_entry_count + self.local_history.len(); + if total_entries == 0 { + return None; + } + + let next_idx = match self.history_cursor { + None => (total_entries as isize) - 1, + Some(0) => return None, // already at oldest + Some(idx) => idx - 1, + }; + + self.history_cursor = Some(next_idx); + self.populate_history_at_index(next_idx as usize, app_event_tx) + } + + /// Handle . + pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { + let total_entries = self.history_entry_count + self.local_history.len(); + if total_entries == 0 { + return None; + } + + let next_idx_opt = match self.history_cursor { + None => return None, // not browsing + Some(idx) if (idx as usize) + 1 >= total_entries => None, + Some(idx) => Some(idx + 1), + }; + + match next_idx_opt { + Some(idx) => { + self.history_cursor = Some(idx); + self.populate_history_at_index(idx as usize, app_event_tx) + } + None => { + // Past newest – clear and exit browsing mode. + self.history_cursor = None; + self.last_history_text = None; + Some(String::new()) + } + } + } + + /// Integrate a GetHistoryEntryResponse event. + pub fn on_entry_response( + &mut self, + log_id: u64, + offset: usize, + entry: Option, + ) -> Option { + if self.history_log_id != Some(log_id) { + return None; + } + let text = entry?; + self.fetched_history.insert(offset, text.clone()); + + if self.history_cursor == Some(offset as isize) { + self.last_history_text = Some(text.clone()); + return Some(text); + } + None + } + + // --------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------- + + fn populate_history_at_index( + &mut self, + global_idx: usize, + app_event_tx: &AppEventSender, + ) -> Option { + if global_idx >= self.history_entry_count { + // Local entry. + if let Some(text) = self + .local_history + .get(global_idx - self.history_entry_count) + { + self.last_history_text = Some(text.clone()); + return Some(text.clone()); + } + } else if let Some(text) = self.fetched_history.get(&global_idx) { + self.last_history_text = Some(text.clone()); + return Some(text.clone()); + } else if let Some(log_id) = self.history_log_id { + let op = Op::GetHistoryEntryRequest { + offset: global_idx, + log_id, + }; + app_event_tx.send(AppEvent::CodexOp(op)); + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use codex_core::protocol::Op; + use tokio::sync::mpsc::unbounded_channel; + + #[test] + fn duplicate_submissions_are_not_recorded() { + let mut history = ChatComposerHistory::new(); + + // Empty submissions are ignored. + history.record_local_submission(""); + assert_eq!(history.local_history.len(), 0); + + // First entry is recorded. + history.record_local_submission("hello"); + assert_eq!(history.local_history.len(), 1); + assert_eq!(history.local_history.last().unwrap(), "hello"); + + // Identical consecutive entry is skipped. + history.record_local_submission("hello"); + assert_eq!(history.local_history.len(), 1); + + // Different entry is recorded. + history.record_local_submission("world"); + assert_eq!(history.local_history.len(), 2); + assert_eq!(history.local_history.last().unwrap(), "world"); + } + + #[test] + fn navigation_with_async_fetch() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + // Pretend there are 3 persistent entries. + history.set_metadata(1, 3); + + // First Up should request offset 2 (latest) and await async data. + assert!(history.should_handle_navigation("", 0)); + assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet + + // Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent. + let event = rx.try_recv().expect("expected AppEvent to be sent"); + let AppEvent::CodexOp(history_request1) = event else { + panic!("unexpected event variant"); + }; + assert_eq!( + Op::GetHistoryEntryRequest { + log_id: 1, + offset: 2 + }, + history_request1 + ); + + // Inject the async response. + assert_eq!( + Some("latest".into()), + history.on_entry_response(1, 2, Some("latest".into())) + ); + + // Next Up should move to offset 1. + assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet + + // Verify second CodexOp event for offset 1. + let event2 = rx.try_recv().expect("expected second event"); + let AppEvent::CodexOp(history_request_2) = event2 else { + panic!("unexpected event variant"); + }; + assert_eq!( + Op::GetHistoryEntryRequest { + log_id: 1, + offset: 1 + }, + history_request_2 + ); + + assert_eq!( + Some("older".into()), + history.on_entry_response(1, 1, Some("older".into())) + ); + } + + #[test] + fn reset_navigation_resets_cursor() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(1, 3); + history.fetched_history.insert(1, "command2".into()); + history.fetched_history.insert(2, "command3".into()); + + assert_eq!(Some("command3".into()), history.navigate_up(&tx)); + assert_eq!(Some("command2".into()), history.navigate_up(&tx)); + + history.reset_navigation(); + assert!(history.history_cursor.is_none()); + assert!(history.last_history_text.is_none()); + + assert_eq!(Some("command3".into()), history.navigate_up(&tx)); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs new file mode 100644 index 00000000000..8aca5c4a625 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -0,0 +1,376 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows; +use crate::render::Insets; +use crate::render::RectExt; +use crate::slash_command::SlashCommand; +use crate::slash_command::built_in_slash_commands; +use codex_common::fuzzy_match::fuzzy_match; +use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; +use std::collections::HashSet; + +/// A selectable item in the popup: either a built-in command or a user prompt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CommandItem { + Builtin(SlashCommand), + // Index into `prompts` + UserPrompt(usize), +} + +pub(crate) struct CommandPopup { + command_filter: String, + builtins: Vec<(&'static str, SlashCommand)>, + prompts: Vec, + state: ScrollState, +} + +impl CommandPopup { + pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .collect(); + // Exclude prompts that collide with builtin command names and sort by name. + let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); + prompts.retain(|p| !exclude.contains(&p.name)); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + Self { + command_filter: String::new(), + builtins, + prompts, + state: ScrollState::new(), + } + } + + pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { + let exclude: HashSet = self + .builtins + .iter() + .map(|(n, _)| (*n).to_string()) + .collect(); + prompts.retain(|p| !exclude.contains(&p.name)); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + self.prompts = prompts; + } + + pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> { + self.prompts.get(idx) + } + + /// Update the filter string based on the current composer text. The text + /// passed in is expected to start with a leading '/'. Everything after the + /// *first* '/" on the *first* line becomes the active filter that is used + /// to narrow down the list of available commands. + pub(crate) fn on_composer_text_change(&mut self, text: String) { + let first_line = text.lines().next().unwrap_or(""); + + if let Some(stripped) = first_line.strip_prefix('/') { + // Extract the *first* token (sequence of non-whitespace + // characters) after the slash so that `/clear something` still + // shows the help for `/clear`. + let token = stripped.trim_start(); + let cmd_token = token.split_whitespace().next().unwrap_or(""); + + // Update the filter keeping the original case (commands are all + // lower-case for now but this may change in the future). + self.command_filter = cmd_token.to_string(); + } else { + // The composer no longer starts with '/'. Reset the filter so the + // popup shows the *full* command list if it is still displayed + // for some reason. + self.command_filter.clear(); + } + + // Reset or clamp selected index based on new filtered list. + let matches_len = self.filtered_items().len(); + self.state.clamp_selection(matches_len); + self.state + .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); + } + + /// 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::measure_rows_height; + let rows = self.rows_from_matches(self.filtered()); + + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) + } + + /// Compute fuzzy-filtered matches over built-in commands and user prompts, + /// paired with optional highlight indices and score. Sorted by ascending + /// score, then by name for stability. + fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { + let filter = self.command_filter.trim(); + let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); + if filter.is_empty() { + // Built-ins first, in presentation order. + for (_, cmd) in self.builtins.iter() { + out.push((CommandItem::Builtin(*cmd), None, 0)); + } + // Then prompts, already sorted by name. + for idx in 0..self.prompts.len() { + out.push((CommandItem::UserPrompt(idx), None, 0)); + } + return out; + } + + for (_, cmd) in self.builtins.iter() { + if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { + out.push((CommandItem::Builtin(*cmd), Some(indices), score)); + } + } + // Support both search styles: + // - Typing "name" should surface "/prompts:name" results. + // - Typing "prompts:name" should also work. + for (idx, p) in self.prompts.iter().enumerate() { + let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); + if let Some((indices, score)) = fuzzy_match(&display, filter) { + out.push((CommandItem::UserPrompt(idx), Some(indices), score)); + } + } + // When filtering, sort by ascending score and then by name for stability. + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = match a.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::UserPrompt(i) => &self.prompts[i].name, + }; + let bn = match b.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::UserPrompt(i) => &self.prompts[i].name, + }; + an.cmp(bn) + }) + }); + out + } + + fn filtered_items(&self) -> Vec { + 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) => { + let prompt = &self.prompts[i]; + let description = prompt + .description + .clone() + .unwrap_or_else(|| "send saved prompt".to_string()); + ( + format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), + description, + ) + } + }; + GenericDisplayRow { + name, + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + display_shortcut: None, + description: Some(description), + wrap_indent: None, + } + }) + .collect() + } + + /// Move the selection cursor one step up. + pub(crate) fn move_up(&mut self) { + let len = self.filtered_items().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + /// Move the selection cursor one step down. + pub(crate) fn move_down(&mut self) { + let matches_len = self.filtered_items().len(); + self.state.move_down_wrap(matches_len); + self.state + .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); + } + + /// Return currently selected command, if any. + pub(crate) fn selected_item(&self) -> Option { + let matches = self.filtered_items(); + self.state + .selected_idx + .and_then(|idx| matches.get(idx).copied()) + } +} + +impl WidgetRef for CommandPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let rows = self.rows_from_matches(self.filtered()); + render_rows( + area.inset(Insets::tlbr(0, 2, 0, 0)), + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no matches", + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn filter_includes_init_when_typing_prefix() { + let mut popup = CommandPopup::new(Vec::new(), false); + // Simulate the composer line starting with '/in' so the popup filters + // matching commands by prefix. + popup.on_composer_text_change("/in".to_string()); + + // Access the filtered list via the selected command and ensure that + // one of the matches is the new "init" command. + let matches = popup.filtered_items(); + let has_init = matches.iter().any(|item| match item { + CommandItem::Builtin(cmd) => cmd.command() == "init", + CommandItem::UserPrompt(_) => false, + }); + assert!( + has_init, + "expected '/init' to appear among filtered commands" + ); + } + + #[test] + fn selecting_init_by_exact_match() { + let mut popup = CommandPopup::new(Vec::new(), false); + popup.on_composer_text_change("/init".to_string()); + + // When an exact match exists, the selected command should be that + // command by default. + let selected = popup.selected_item(); + match selected { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"), + None => panic!("expected a selected command for exact match"), + } + } + + #[test] + fn model_is_first_suggestion_for_mo() { + let mut popup = CommandPopup::new(Vec::new(), false); + popup.on_composer_text_change("/mo".to_string()); + let matches = popup.filtered_items(); + match matches.first() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt ranked before '/model' for '/mo'") + } + None => panic!("expected at least one match for '/mo'"), + } + } + + #[test] + fn prompt_discovery_lists_custom_prompts() { + let prompts = vec![ + CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "hello from foo".to_string(), + description: None, + argument_hint: None, + }, + CustomPrompt { + name: "bar".to_string(), + path: "/tmp/bar.md".to_string().into(), + content: "hello from bar".to_string(), + description: None, + argument_hint: None, + }, + ]; + let popup = CommandPopup::new(prompts, false); + let items = popup.filtered_items(); + let mut prompt_names: Vec = items + .into_iter() + .filter_map(|it| match it { + CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()), + _ => None, + }) + .collect(); + prompt_names.sort(); + assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]); + } + + #[test] + fn prompt_name_collision_with_builtin_is_ignored() { + // Create a prompt named like a builtin (e.g. "init"). + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "init".to_string(), + path: "/tmp/init.md".to_string().into(), + content: "should be ignored".to_string(), + description: None, + argument_hint: None, + }], + false, + ); + let items = popup.filtered_items(); + let has_collision_prompt = items.into_iter().any(|it| match it { + CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), + _ => false, + }); + assert!( + !has_collision_prompt, + "prompt with builtin name should be ignored" + ); + } + + #[test] + fn prompt_description_uses_frontmatter_metadata() { + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "draftpr".to_string(), + path: "/tmp/draftpr.md".to_string().into(), + content: "body".to_string(), + description: Some("Create feature branch, commit and open draft PR.".to_string()), + argument_hint: None, + }], + false, + ); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!( + description, + Some("Create feature branch, commit and open draft PR.") + ); + } + + #[test] + fn prompt_description_falls_back_when_missing() { + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "body".to_string(), + description: None, + argument_hint: None, + }], + false, + ); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!(description, Some("send saved prompt")); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs new file mode 100644 index 00000000000..e9f0ee697f9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs @@ -0,0 +1,247 @@ +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 crate::render::renderable::Renderable; + +use super::popup_consts::standard_popup_hint_line; + +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, + + // UI state + textarea: TextArea, + textarea_state: RefCell, + complete: bool, +} + +impl CustomPromptView { + pub(crate) fn new( + title: String, + placeholder: String, + context_label: Option, + on_submit: PromptSubmitted, + ) -> Self { + Self { + title, + placeholder, + context_label, + on_submit, + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + complete: false, + } + } +} + +impl BottomPaneView for CustomPromptView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + 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) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + true + } +} + +impl Renderable for CustomPromptView { + 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 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/tui2/src/bottom_pane/feedback_view.rs b/codex-rs/tui2/src/bottom_pane/feedback_view.rs new file mode 100644 index 00000000000..c563ab8e90b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/feedback_view.rs @@ -0,0 +1,559 @@ +use std::cell::RefCell; +use std::path::PathBuf; + +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 crate::app_event::AppEvent; +use crate::app_event::FeedbackCategory; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; +use crate::render::renderable::Renderable; +use codex_core::protocol::SessionSource; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::standard_popup_hint_line; +use super::textarea::TextArea; +use super::textarea::TextAreaState; + +const BASE_BUG_ISSUE_URL: &str = + "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; + +/// Minimal input overlay to collect an optional feedback note, then upload +/// both logs and rollout with classification + metadata. +pub(crate) struct FeedbackNoteView { + category: FeedbackCategory, + snapshot: codex_feedback::CodexLogSnapshot, + rollout_path: Option, + app_event_tx: AppEventSender, + include_logs: bool, + + // UI state + textarea: TextArea, + textarea_state: RefCell, + complete: bool, +} + +impl FeedbackNoteView { + pub(crate) fn new( + category: FeedbackCategory, + snapshot: codex_feedback::CodexLogSnapshot, + rollout_path: Option, + app_event_tx: AppEventSender, + include_logs: bool, + ) -> Self { + Self { + category, + snapshot, + rollout_path, + app_event_tx, + include_logs, + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + complete: false, + } + } + + fn submit(&mut self) { + let note = self.textarea.text().trim().to_string(); + let reason_opt = if note.is_empty() { + None + } else { + Some(note.as_str()) + }; + let rollout_path_ref = self.rollout_path.as_deref(); + let classification = feedback_classification(self.category); + + let mut thread_id = self.snapshot.thread_id.clone(); + + let result = self.snapshot.upload_feedback( + classification, + reason_opt, + self.include_logs, + if self.include_logs { + rollout_path_ref + } else { + None + }, + Some(SessionSource::Cli), + ); + + match result { + Ok(()) => { + let prefix = if self.include_logs { + "• Feedback uploaded." + } else { + "• Feedback recorded (no logs)." + }; + let issue_url = issue_url_for_category(self.category, &thread_id); + let mut lines = vec![Line::from(match issue_url.as_ref() { + Some(_) => format!("{prefix} Please open an issue using the following URL:"), + None => format!("{prefix} Thanks for the feedback!"), + })]; + if let Some(url) = issue_url { + lines.extend([ + "".into(), + Line::from(vec![" ".into(), url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + std::mem::take(&mut thread_id).bold(), + " in an existing issue.".into(), + ]), + ]); + } else { + lines.extend([ + "".into(), + Line::from(vec![ + " Thread ID: ".into(), + std::mem::take(&mut thread_id).bold(), + ]), + ]); + } + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::PlainHistoryCell::new(lines), + ))); + } + Err(e) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!("Failed to upload feedback: {e}")), + ))); + } + } + self.complete = true; + } +} + +impl BottomPaneView for FeedbackNoteView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + self.submit(); + } + KeyEvent { + code: KeyCode::Enter, + .. + } => { + self.textarea.input(key_event); + } + other => { + self.textarea.input(other); + } + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + true + } +} + +impl Renderable for FeedbackNoteView { + fn desired_height(&self, width: u16) -> u16 { + 1u16 + self.input_height(width) + 3u16 + } + + 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 top_line_count = 1u16; // title only + 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) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let (title, placeholder) = feedback_title_and_placeholder(self.category); + 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(), title.bold()]; + Paragraph::new(Line::from(title_spans)).render(title_area, buf); + + // Input line + let input_area = Rect { + x: area.x, + y: area.y.saturating_add(1), + 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(placeholder.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, + ); + } + } +} + +impl FeedbackNoteView { + 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() +} + +fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) { + match category { + FeedbackCategory::BadResult => ( + "Tell us more (bad result)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::GoodResult => ( + "Tell us more (good result)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::Bug => ( + "Tell us more (bug)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::Other => ( + "Tell us more (other)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + } +} + +fn feedback_classification(category: FeedbackCategory) -> &'static str { + match category { + FeedbackCategory::BadResult => "bad_result", + FeedbackCategory::GoodResult => "good_result", + FeedbackCategory::Bug => "bug", + FeedbackCategory::Other => "other", + } +} + +fn issue_url_for_category(category: FeedbackCategory, thread_id: &str) -> Option { + match category { + FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => Some( + format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}"), + ), + FeedbackCategory::GoodResult => None, + } +} + +// Build the selection popup params for feedback categories. +pub(crate) fn feedback_selection_params( + app_event_tx: AppEventSender, +) -> super::SelectionViewParams { + super::SelectionViewParams { + title: Some("How was this?".to_string()), + items: vec![ + make_feedback_item( + app_event_tx.clone(), + "bug", + "Crash, error message, hang, or broken UI/behavior.", + FeedbackCategory::Bug, + ), + make_feedback_item( + app_event_tx.clone(), + "bad result", + "Output was off-target, incorrect, incomplete, or unhelpful.", + FeedbackCategory::BadResult, + ), + make_feedback_item( + app_event_tx.clone(), + "good result", + "Helpful, correct, high‑quality, or delightful result worth celebrating.", + FeedbackCategory::GoodResult, + ), + make_feedback_item( + app_event_tx, + "other", + "Slowness, feature suggestion, UX feedback, or anything else.", + FeedbackCategory::Other, + ), + ], + ..Default::default() + } +} + +fn make_feedback_item( + app_event_tx: AppEventSender, + name: &str, + description: &str, + category: FeedbackCategory, +) -> super::SelectionItem { + let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| { + app_event_tx.send(AppEvent::OpenFeedbackConsent { category }); + }); + super::SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + actions: vec![action], + dismiss_on_select: true, + ..Default::default() + } +} + +/// Build the upload consent popup params for a given feedback category. +pub(crate) fn feedback_upload_consent_params( + app_event_tx: AppEventSender, + category: FeedbackCategory, + rollout_path: Option, +) -> super::SelectionViewParams { + use super::popup_consts::standard_popup_hint_line; + let yes_action: super::SelectionAction = Box::new({ + let tx = app_event_tx.clone(); + move |sender: &AppEventSender| { + let _ = sender; + tx.send(AppEvent::OpenFeedbackNote { + category, + include_logs: true, + }); + } + }); + + let no_action: super::SelectionAction = Box::new({ + let tx = app_event_tx; + move |sender: &AppEventSender| { + let _ = sender; + tx.send(AppEvent::OpenFeedbackNote { + category, + include_logs: false, + }); + } + }); + + // Build header listing files that would be sent if user consents. + let mut header_lines: Vec> = vec![ + Line::from("Upload logs?".bold()).into(), + Line::from("").into(), + Line::from("The following files will be sent:".dim()).into(), + Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), + ]; + if let Some(path) = rollout_path.as_deref() + && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) + { + header_lines.push(Line::from(vec![" • ".into(), name.into()]).into()); + } + + super::SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + super::SelectionItem { + name: "Yes".to_string(), + description: Some( + "Share the current Codex session logs with the team for troubleshooting." + .to_string(), + ), + actions: vec![yes_action], + dismiss_on_select: true, + ..Default::default() + }, + super::SelectionItem { + name: "No".to_string(), + description: Some("".to_string()), + actions: vec![no_action], + dismiss_on_select: true, + ..Default::default() + }, + ], + header: Box::new(crate::render::renderable::ColumnRenderable::with( + header_lines, + )), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + + fn render(view: &FeedbackNoteView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let mut lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect(); + + while lines.first().is_some_and(|l| l.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.trim().is_empty()) { + lines.pop(); + } + lines.join("\n") + } + + fn make_view(category: FeedbackCategory) -> FeedbackNoteView { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let snapshot = codex_feedback::CodexFeedback::new().snapshot(None); + FeedbackNoteView::new(category, snapshot, None, tx, true) + } + + #[test] + fn feedback_view_bad_result() { + let view = make_view(FeedbackCategory::BadResult); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_bad_result", rendered); + } + + #[test] + fn feedback_view_good_result() { + let view = make_view(FeedbackCategory::GoodResult); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_good_result", rendered); + } + + #[test] + fn feedback_view_bug() { + let view = make_view(FeedbackCategory::Bug); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_bug", rendered); + } + + #[test] + fn feedback_view_other() { + let view = make_view(FeedbackCategory::Other); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_other", rendered); + } + + #[test] + fn issue_url_available_for_bug_bad_result_and_other() { + let bug_url = issue_url_for_category(FeedbackCategory::Bug, "thread-1"); + assert!( + bug_url + .as_deref() + .is_some_and(|url| url.contains("template=2-bug-report")) + ); + + let bad_result_url = issue_url_for_category(FeedbackCategory::BadResult, "thread-2"); + assert!(bad_result_url.is_some()); + + let other_url = issue_url_for_category(FeedbackCategory::Other, "thread-3"); + assert!(other_url.is_some()); + + assert!(issue_url_for_category(FeedbackCategory::GoodResult, "t").is_none()); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/file_search_popup.rs b/codex-rs/tui2/src/bottom_pane/file_search_popup.rs new file mode 100644 index 00000000000..064e4f01370 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/file_search_popup.rs @@ -0,0 +1,154 @@ +use codex_file_search::FileMatch; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use crate::render::Insets; +use crate::render::RectExt; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows; + +/// Visual state for the file-search popup. +pub(crate) struct FileSearchPopup { + /// Query corresponding to the `matches` currently shown. + display_query: String, + /// Latest query typed by the user. May differ from `display_query` when + /// a search is still in-flight. + pending_query: String, + /// When `true` we are still waiting for results for `pending_query`. + waiting: bool, + /// Cached matches; paths relative to the search dir. + matches: Vec, + /// Shared selection/scroll state. + state: ScrollState, +} + +impl FileSearchPopup { + pub(crate) fn new() -> Self { + Self { + display_query: String::new(), + pending_query: String::new(), + waiting: true, + matches: Vec::new(), + state: ScrollState::new(), + } + } + + /// Update the query and reset state to *waiting*. + pub(crate) fn set_query(&mut self, query: &str) { + if query == self.pending_query { + return; + } + + // Determine if current matches are still relevant. + let keep_existing = query.starts_with(&self.display_query); + + self.pending_query.clear(); + self.pending_query.push_str(query); + + self.waiting = true; // waiting for new results + + if !keep_existing { + self.matches.clear(); + self.state.reset(); + } + } + + /// Put the popup into an "idle" state used for an empty query (just "@"). + /// Shows a hint instead of matches until the user types more characters. + pub(crate) fn set_empty_prompt(&mut self) { + self.display_query.clear(); + self.pending_query.clear(); + self.waiting = false; + self.matches.clear(); + // Reset selection/scroll state when showing the empty prompt. + self.state.reset(); + } + + /// Replace matches when a `FileSearchResult` arrives. + /// Replace matches. Only applied when `query` matches `pending_query`. + pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { + if query != self.pending_query { + return; // stale + } + + self.display_query = query.to_string(); + self.matches = matches; + self.waiting = false; + let len = self.matches.len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); + } + + /// Move selection cursor up. + pub(crate) fn move_up(&mut self) { + let len = self.matches.len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); + } + + /// Move selection cursor down. + pub(crate) fn move_down(&mut self) { + let len = self.matches.len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); + } + + pub(crate) fn selected_match(&self) -> Option<&str> { + self.state + .selected_idx + .and_then(|idx| self.matches.get(idx)) + .map(|file_match| file_match.path.as_str()) + } + + pub(crate) fn calculate_required_height(&self) -> u16 { + // Row count depends on whether we already have matches. If no matches + // yet (e.g. initial search or query with no results) reserve a single + // row so the popup is still visible. When matches are present we show + // up to MAX_RESULTS regardless of the waiting flag so the list + // remains stable while a newer search is in-flight. + + self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16 + } +} + +impl WidgetRef for &FileSearchPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary. + let rows_all: Vec = if self.matches.is_empty() { + Vec::new() + } else { + self.matches + .iter() + .map(|m| GenericDisplayRow { + name: m.path.clone(), + match_indices: m + .indices + .as_ref() + .map(|v| v.iter().map(|&i| i as usize).collect()), + display_shortcut: None, + description: None, + wrap_indent: None, + }) + .collect() + }; + + let empty_message = if self.waiting { + "loading..." + } else { + "no matches" + }; + + render_rows( + area.inset(Insets::tlbr(0, 2, 0, 0)), + buf, + &rows_all, + &self.state, + MAX_POPUP_ROWS, + empty_message, + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs new file mode 100644 index 00000000000..d47ffec98b6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -0,0 +1,530 @@ +#[cfg(target_os = "linux")] +use crate::clipboard_paste::is_probably_wsl; +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::render::line_utils::prefix_lines; +use crate::status::format_tokens_compact; +use crate::ui_consts::FOOTER_INDENT_COLS; +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct FooterProps { + pub(crate) mode: FooterMode, + pub(crate) esc_backtrack_hint: bool, + pub(crate) use_shift_enter_hint: bool, + pub(crate) is_task_running: bool, + pub(crate) context_window_percent: Option, + pub(crate) context_window_used_tokens: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FooterMode { + CtrlCReminder, + ShortcutSummary, + ShortcutOverlay, + EscHint, + ContextOnly, +} + +pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { + if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) { + return current; + } + + match current { + FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary, + _ => FooterMode::ShortcutOverlay, + } +} + +pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { + if is_task_running { + current + } else { + FooterMode::EscHint + } +} + +pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { + match current { + FooterMode::EscHint + | FooterMode::ShortcutOverlay + | FooterMode::CtrlCReminder + | FooterMode::ContextOnly => FooterMode::ShortcutSummary, + other => other, + } +} + +pub(crate) fn footer_height(props: FooterProps) -> u16 { + footer_lines(props).len() as u16 +} + +pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { + Paragraph::new(prefix_lines( + footer_lines(props), + " ".repeat(FOOTER_INDENT_COLS).into(), + " ".repeat(FOOTER_INDENT_COLS).into(), + )) + .render(area, buf); +} + +fn footer_lines(props: FooterProps) -> Vec> { + // Show the context indicator on the left, appended after the primary hint + // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when + // the shortcut hint is hidden). Hide it only for the multi-line + // ShortcutOverlay. + match props.mode { + FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState { + is_task_running: props.is_task_running, + })], + FooterMode::ShortcutSummary => { + let mut line = context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + ); + line.push_span(" · ".dim()); + line.extend(vec![ + key_hint::plain(KeyCode::Char('?')).into(), + " for shortcuts".dim(), + ]); + vec![line] + } + FooterMode::ShortcutOverlay => { + #[cfg(target_os = "linux")] + let is_wsl = is_probably_wsl(); + #[cfg(not(target_os = "linux"))] + let is_wsl = false; + + let state = ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + is_wsl, + }; + shortcut_overlay_lines(state) + } + FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], + FooterMode::ContextOnly => vec![context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + )], + } +} + +#[derive(Clone, Copy, Debug)] +struct CtrlCReminderState { + is_task_running: bool, +} + +#[derive(Clone, Copy, Debug)] +struct ShortcutsState { + use_shift_enter_hint: bool, + esc_backtrack_hint: bool, + is_wsl: bool, +} + +fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { + let action = if state.is_task_running { + "interrupt" + } else { + "quit" + }; + Line::from(vec![ + key_hint::ctrl(KeyCode::Char('c')).into(), + format!(" again to {action}").into(), + ]) + .dim() +} + +fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { + let esc = key_hint::plain(KeyCode::Esc); + if esc_backtrack_hint { + Line::from(vec![esc.into(), " again to edit previous message".into()]).dim() + } else { + Line::from(vec![ + esc.into(), + " ".into(), + esc.into(), + " to edit previous message".into(), + ]) + .dim() + } +} + +fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { + let mut commands = Line::from(""); + let mut newline = Line::from(""); + let mut file_paths = Line::from(""); + let mut paste_image = Line::from(""); + let mut edit_previous = Line::from(""); + let mut quit = Line::from(""); + let mut show_transcript = Line::from(""); + + for descriptor in SHORTCUTS { + if let Some(text) = descriptor.overlay_entry(state) { + match descriptor.id { + ShortcutId::Commands => commands = text, + ShortcutId::InsertNewline => newline = text, + ShortcutId::FilePaths => file_paths = text, + ShortcutId::PasteImage => paste_image = text, + ShortcutId::EditPrevious => edit_previous = text, + ShortcutId::Quit => quit = text, + ShortcutId::ShowTranscript => show_transcript = text, + } + } + } + + let ordered = vec![ + commands, + newline, + file_paths, + paste_image, + edit_previous, + quit, + Line::from(""), + show_transcript, + ]; + + build_columns(ordered) +} + +fn build_columns(entries: Vec>) -> Vec> { + if entries.is_empty() { + return Vec::new(); + } + + const COLUMNS: usize = 2; + const COLUMN_PADDING: [usize; COLUMNS] = [4, 4]; + const COLUMN_GAP: usize = 4; + + let rows = entries.len().div_ceil(COLUMNS); + let target_len = rows * COLUMNS; + let mut entries = entries; + if entries.len() < target_len { + entries.extend(std::iter::repeat_n( + Line::from(""), + target_len - entries.len(), + )); + } + + let mut column_widths = [0usize; COLUMNS]; + + for (idx, entry) in entries.iter().enumerate() { + let column = idx % COLUMNS; + column_widths[column] = column_widths[column].max(entry.width()); + } + + for (idx, width) in column_widths.iter_mut().enumerate() { + *width += COLUMN_PADDING[idx]; + } + + entries + .chunks(COLUMNS) + .map(|chunk| { + let mut line = Line::from(""); + for (col, entry) in chunk.iter().enumerate() { + line.extend(entry.spans.clone()); + if col < COLUMNS - 1 { + let target_width = column_widths[col]; + let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP; + line.push_span(Span::from(" ".repeat(padding))); + } + } + line.dim() + }) + .collect() +} + +fn context_window_line(percent: Option, used_tokens: Option) -> Line<'static> { + if let Some(percent) = percent { + let percent = percent.clamp(0, 100); + return Line::from(vec![Span::from(format!("{percent}% context left")).dim()]); + } + + if let Some(tokens) = used_tokens { + let used_fmt = format_tokens_compact(tokens); + return Line::from(vec![Span::from(format!("{used_fmt} used")).dim()]); + } + + Line::from(vec![Span::from("100% context left").dim()]) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShortcutId { + Commands, + InsertNewline, + FilePaths, + PasteImage, + EditPrevious, + Quit, + ShowTranscript, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ShortcutBinding { + key: KeyBinding, + condition: DisplayCondition, +} + +impl ShortcutBinding { + fn matches(&self, state: ShortcutsState) -> bool { + self.condition.matches(state) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DisplayCondition { + Always, + WhenShiftEnterHint, + WhenNotShiftEnterHint, + WhenUnderWSL, +} + +impl DisplayCondition { + fn matches(self, state: ShortcutsState) -> bool { + match self { + DisplayCondition::Always => true, + DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, + DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, + DisplayCondition::WhenUnderWSL => state.is_wsl, + } + } +} + +struct ShortcutDescriptor { + id: ShortcutId, + bindings: &'static [ShortcutBinding], + prefix: &'static str, + label: &'static str, +} + +impl ShortcutDescriptor { + fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { + self.bindings.iter().find(|binding| binding.matches(state)) + } + + fn overlay_entry(&self, state: ShortcutsState) -> Option> { + let binding = self.binding_for(state)?; + let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]); + match self.id { + ShortcutId::EditPrevious => { + if state.esc_backtrack_hint { + line.push_span(" again to edit previous message"); + } else { + line.extend(vec![ + " ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to edit previous message".into(), + ]); + } + } + _ => line.push_span(self.label), + }; + Some(line) + } +} + +const SHORTCUTS: &[ShortcutDescriptor] = &[ + ShortcutDescriptor { + id: ShortcutId::Commands, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Char('/')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for commands", + }, + ShortcutDescriptor { + id: ShortcutId::InsertNewline, + bindings: &[ + ShortcutBinding { + key: key_hint::shift(KeyCode::Enter), + condition: DisplayCondition::WhenShiftEnterHint, + }, + ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('j')), + condition: DisplayCondition::WhenNotShiftEnterHint, + }, + ], + prefix: "", + label: " for newline", + }, + ShortcutDescriptor { + id: ShortcutId::FilePaths, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Char('@')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for file paths", + }, + ShortcutDescriptor { + id: ShortcutId::PasteImage, + // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain + // Ctrl+V); otherwise fall back to Ctrl+V. + bindings: &[ + ShortcutBinding { + key: key_hint::ctrl_alt(KeyCode::Char('v')), + condition: DisplayCondition::WhenUnderWSL, + }, + ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('v')), + condition: DisplayCondition::Always, + }, + ], + prefix: "", + label: " to paste images", + }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Esc), + condition: DisplayCondition::Always, + }], + prefix: "", + label: "", + }, + ShortcutDescriptor { + id: ShortcutId::Quit, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('c')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to exit", + }, + ShortcutDescriptor { + id: ShortcutId::ShowTranscript, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('t')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to view transcript", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn snapshot_footer(name: &str, props: FooterProps) { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); + terminal + .draw(|f| { + let area = Rect::new(0, 0, f.area().width, height); + render_footer(area, f.buffer_mut(), props); + }) + .unwrap(); + assert_snapshot!(name, terminal.backend()); + } + + #[test] + fn footer_snapshots() { + snapshot_footer( + "footer_shortcuts_default", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_shift_and_esc", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: true, + use_shift_enter_hint: true, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_idle", + FooterProps { + mode: FooterMode::CtrlCReminder, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_running", + FooterProps { + mode: FooterMode::CtrlCReminder, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_esc_hint_idle", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_esc_hint_primed", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: true, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_context_running", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + context_window_percent: Some(72), + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_context_tokens_used", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: Some(123_456), + }, + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs new file mode 100644 index 00000000000..d23fd8ed3b6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs @@ -0,0 +1,794 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use itertools::Itertools as _; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::app_event_sender::AppEventSender; +use crate::key_hint::KeyBinding; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; +use unicode_width::UnicodeWidthStr; + +/// One selectable item in the generic selection list. +pub(crate) type SelectionAction = Box; + +#[derive(Default)] +pub(crate) struct SelectionItem { + pub name: String, + pub display_shortcut: Option, + pub description: Option, + pub selected_description: Option, + pub is_current: bool, + pub actions: Vec, + pub dismiss_on_select: bool, + pub search_value: Option, +} + +pub(crate) struct SelectionViewParams { + pub title: Option, + pub subtitle: Option, + pub footer_hint: Option>, + pub items: Vec, + pub is_searchable: bool, + pub search_placeholder: Option, + pub header: Box, + pub initial_selected_idx: Option, +} + +impl Default for SelectionViewParams { + fn default() -> Self { + Self { + title: None, + subtitle: None, + footer_hint: None, + items: Vec::new(), + is_searchable: false, + search_placeholder: None, + header: Box::new(()), + initial_selected_idx: None, + } + } +} + +pub(crate) struct ListSelectionView { + footer_hint: Option>, + items: Vec, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + is_searchable: bool, + search_query: String, + search_placeholder: Option, + filtered_indices: Vec, + last_selected_actual_idx: Option, + header: Box, + initial_selected_idx: Option, +} + +impl ListSelectionView { + pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { + let mut header = params.header; + if params.title.is_some() || params.subtitle.is_some() { + let title = params.title.map(|title| Line::from(title.bold())); + let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim())); + header = Box::new(ColumnRenderable::with([ + header, + Box::new(title), + Box::new(subtitle), + ])); + } + let mut s = Self { + footer_hint: params.footer_hint, + items: params.items, + state: ScrollState::new(), + complete: false, + app_event_tx, + is_searchable: params.is_searchable, + search_query: String::new(), + search_placeholder: if params.is_searchable { + params.search_placeholder + } else { + None + }, + filtered_indices: Vec::new(), + last_selected_actual_idx: None, + header, + initial_selected_idx: params.initial_selected_idx, + }; + 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() + }) + .or_else(|| self.initial_selected_idx.take()); + + if self.is_searchable && !self.search_query.is_empty() { + let query_lower = self.search_query.to_lowercase(); + self.filtered_indices = self + .items + .iter() + .positions(|item| { + item.search_value + .as_ref() + .is_some_and(|v| v.to_lowercase().contains(&query_lower)) + }) + .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 wrap_prefix = if self.is_searchable { + // The number keys don't work when search is enabled (since we let the + // numbers be used for the search query). + format!("{prefix} ") + } else { + format!("{prefix} {n}. ") + }; + let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); + let display_name = format!("{wrap_prefix}{name_with_marker}"); + let description = is_selected + .then(|| item.selected_description.clone()) + .flatten() + .or_else(|| item.description.clone()); + let wrap_indent = description.is_none().then_some(wrap_prefix_width); + GenericDisplayRow { + name: display_name, + display_shortcut: item.display_shortcut, + match_indices: None, + description, + wrap_indent, + } + }) + }) + .collect() + } + + fn move_up(&mut self) { + let len = self.visible_len(); + self.state.move_up_wrap(len); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); + } + + fn move_down(&mut self) { + let len = self.visible_len(); + self.state.move_down_wrap(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 + && let Some(actual_idx) = self.filtered_indices.get(idx) + && let Some(item) = self.items.get(*actual_idx) + { + self.last_selected_actual_idx = Some(*actual_idx); + for act in &item.actions { + act(&self.app_event_tx); + } + if item.dismiss_on_select { + self.complete = true; + } + } else { + self.complete = true; + } + } + + #[cfg(test)] + pub(crate) fn set_search_query(&mut self, query: String) { + self.search_query = query; + self.apply_filter(); + } + + pub(crate) fn take_last_selected_index(&mut self) -> Option { + self.last_selected_actual_idx.take() + } + + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } +} + +impl BottomPaneView for ListSelectionView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + // Some terminals (or configurations) send Control key chords as + // C0 control characters without reporting the CONTROL modifier. + // Handle fallbacks for Ctrl-P/N here so navigation works everywhere. + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^P */ => self.move_up(), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } if !self.is_searchable => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^N */ => self.move_down(), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } if !self.is_searchable => self.move_down(), + KeyEvent { + code: KeyCode::Backspace, + .. + } if self.is_searchable => { + self.search_query.pop(); + self.apply_filter(); + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + 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::Char(c), + modifiers, + .. + } if !self.is_searchable + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + if let Some(idx) = c + .to_digit(10) + .map(|d| d as usize) + .and_then(|d| d.checked_sub(1)) + && idx < self.items.len() + { + self.state.selected_idx = Some(idx); + self.accept(); + } + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.accept(), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } +} + +impl Renderable for ListSelectionView { + 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 = self.build_rows(); + let rows_width = Self::rows_width(width); + let rows_height = measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + + // Subtract 4 for the padding on the left and right of the header. + let mut height = self.header.desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 3); + if self.is_searchable { + height = height.saturating_add(1); + } + if self.footer_hint.is_some() { + height = height.saturating_add(1); + } + height + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let [content_area, footer_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), + ]) + .areas(area); + + Block::default() + .style(user_message_style()) + .render(content_area, buf); + + let header_height = self + .header + // Subtract 4 for the padding on the left and right of the header. + .desired_height(content_area.width.saturating_sub(4)); + let rows = self.build_rows(); + let rows_width = Self::rows_width(content_area.width); + let rows_height = measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + let [header_area, _, search_area, list_area] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(1), + Constraint::Length(if self.is_searchable { 1 } else { 0 }), + Constraint::Length(rows_height), + ]) + .areas(content_area.inset(Insets::vh(1, 2))); + + if header_area.height < header_height { + let [header_area, elision_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area); + self.header.render(header_area, buf); + Paragraph::new(vec![ + Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(), + ]) + .render(elision_area, buf); + } else { + self.header.render(header_area, buf); + } + + if self.is_searchable { + Line::from(self.search_query.clone()).render(search_area, buf); + 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() + }; + Line::from(query_span).render(search_area, buf); + } + + if list_area.height > 0 { + let render_area = Rect { + x: list_area.x.saturating_sub(2), + y: list_area.y, + width: rows_width.max(1), + height: list_area.height, + }; + render_rows( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + ); + } + + if let Some(hint) = &self.footer_hint { + let hint_area = Rect { + x: footer_area.x + 2, + y: footer_area.y, + width: footer_area.width.saturating_sub(2), + height: footer_area.height, + }; + hint.clone().dim().render(hint_area, buf); + } + } +} + +#[cfg(test)] +mod tests { + 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; + + fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { + 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: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Full Access".to_string(), + description: Some("Codex can edit files".to_string()), + is_current: false, + dismiss_on_select: true, + ..Default::default() + }, + ]; + ListSelectionView::new( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + subtitle: subtitle.map(str::to_string), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }, + tx, + ) + } + + fn render_lines(view: &ListSelectionView) -> String { + render_lines_with_width(view, 48) + } + + fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line + }) + .collect(); + lines.join("\n") + } + + #[test] + fn renders_blank_line_between_title_and_items_without_subtitle() { + let view = make_selection_view(None); + assert_snapshot!( + "list_selection_spacing_without_subtitle", + render_lines(&view) + ); + } + + #[test] + fn renders_blank_line_between_subtitle_and_items() { + 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, + dismiss_on_select: true, + ..Default::default() + }]; + let mut view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_hint: Some(standard_popup_hint_line()), + 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"), + "expected search query line to include rendered query, got {lines:?}" + ); + } + + #[test] + fn wraps_long_option_without_overflowing_columns() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "Yes, proceed".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again for commands that start with `python -mpre_commit run --files eslint-plugin/no-mixed-const-enum-exports.js`".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Approval".to_string()), + items, + ..Default::default() + }, + tx, + ); + + let rendered = render_lines_with_width(&view, 60); + let command_line = rendered + .lines() + .find(|line| line.contains("python -mpre_commit run")) + .expect("rendered lines should include wrapped command"); + assert!( + command_line.starts_with(" `python -mpre_commit run"), + "wrapped command line should align under the numbered prefix:\n{rendered}" + ); + assert!( + rendered.contains("eslint-plugin/no-") + && rendered.contains("mixed-const-enum-exports.js"), + "long command should not be truncated even when wrapped:\n{rendered}" + ); + } + + #[test] + fn width_changes_do_not_hide_rows() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "gpt-5.1-codex".to_string(), + description: Some( + "Optimized for Codex. Balance of reasoning quality and coding ability." + .to_string(), + ), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-5.1-codex-mini".to_string(), + description: Some( + "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-4.1-codex".to_string(), + description: Some( + "Legacy model. Use when you need compatibility with older automations." + .to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Model and Effort".to_string()), + items, + ..Default::default() + }, + tx, + ); + let mut missing: Vec = Vec::new(); + for width in 60..=90 { + let rendered = render_lines_with_width(&view, width); + if !rendered.contains("3.") { + missing.push(width); + } + } + assert!( + missing.is_empty(), + "third option missing at widths {missing:?}" + ); + } + + #[test] + fn narrow_width_keeps_all_rows_visible() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let desc = "x".repeat(10); + let items: Vec = (1..=3) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + description: Some(desc.clone()), + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items, + ..Default::default() + }, + tx, + ); + let rendered = render_lines_with_width(&view, 24); + assert!( + rendered.contains("3."), + "third option missing for width 24:\n{rendered}" + ); + } + + #[test] + fn snapshot_model_picker_width_80() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "gpt-5.1-codex".to_string(), + description: Some( + "Optimized for Codex. Balance of reasoning quality and coding ability." + .to_string(), + ), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-5.1-codex-mini".to_string(), + description: Some( + "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-4.1-codex".to_string(), + description: Some( + "Legacy model. Use when you need compatibility with older automations." + .to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Model and Effort".to_string()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_model_picker_width_80", + render_lines_with_width(&view, 80) + ); + } + + #[test] + fn snapshot_narrow_width_preserves_third_option() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let desc = "x".repeat(10); + let items: Vec = (1..=3) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + description: Some(desc.clone()), + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_narrow_width_preserves_rows", + render_lines_with_width(&view, 24) + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs new file mode 100644 index 00000000000..554810de7f0 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -0,0 +1,814 @@ +//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. +use std::path::PathBuf; + +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::queued_user_messages::QueuedUserMessages; +use crate::render::renderable::FlexRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableItem; +use crate::tui::FrameRequester; +use bottom_pane_view::BottomPaneView; +use codex_core::features::Features; +use codex_core::skills::model::SkillMetadata; +use codex_file_search::FileMatch; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use std::time::Duration; + +mod approval_overlay; +pub(crate) use approval_overlay::ApprovalOverlay; +pub(crate) use approval_overlay::ApprovalRequest; +mod bottom_pane_view; +mod chat_composer; +mod chat_composer_history; +mod command_popup; +pub mod custom_prompt_view; +mod file_search_popup; +mod footer; +mod list_selection_view; +mod prompt_args; +mod skill_popup; +pub(crate) use list_selection_view::SelectionViewParams; +mod feedback_view; +pub(crate) use feedback_view::feedback_selection_params; +pub(crate) use feedback_view::feedback_upload_consent_params; +mod paste_burst; +pub mod popup_consts; +mod queued_user_messages; +mod scroll_state; +mod selection_popup_common; +mod textarea; +pub(crate) use feedback_view::FeedbackNoteView; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CancellationEvent { + Handled, + NotHandled, +} + +pub(crate) use chat_composer::ChatComposer; +pub(crate) use chat_composer::InputResult; +use codex_protocol::custom_prompts::CustomPrompt; + +use crate::status_indicator_widget::StatusIndicatorWidget; +pub(crate) use list_selection_view::SelectionAction; +pub(crate) use list_selection_view::SelectionItem; + +/// Pane displayed in the lower half of the chat UI. +pub(crate) struct BottomPane { + /// Composer is retained even when a BottomPaneView is displayed so the + /// input state is retained when the view is closed. + composer: ChatComposer, + + /// Stack of views displayed instead of the composer (e.g. popups/modals). + view_stack: Vec>, + + app_event_tx: AppEventSender, + frame_requester: FrameRequester, + + has_input_focus: bool, + is_task_running: bool, + ctrl_c_quit_hint: bool, + esc_backtrack_hint: bool, + animations_enabled: bool, + + /// Inline status indicator shown above the composer while a task is running. + status: Option, + /// Queued user messages to show above the composer while a turn is running. + queued_user_messages: QueuedUserMessages, + context_window_percent: Option, + context_window_used_tokens: Option, +} + +pub(crate) struct BottomPaneParams { + pub(crate) app_event_tx: AppEventSender, + pub(crate) frame_requester: FrameRequester, + pub(crate) has_input_focus: bool, + pub(crate) enhanced_keys_supported: bool, + pub(crate) placeholder_text: String, + pub(crate) disable_paste_burst: bool, + pub(crate) animations_enabled: bool, + pub(crate) skills: Option>, +} + +impl BottomPane { + pub fn new(params: BottomPaneParams) -> Self { + let BottomPaneParams { + app_event_tx, + frame_requester, + has_input_focus, + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + animations_enabled, + skills, + } = params; + let mut composer = ChatComposer::new( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + ); + composer.set_skill_mentions(skills); + + Self { + composer, + view_stack: Vec::new(), + app_event_tx, + frame_requester, + has_input_focus, + is_task_running: false, + ctrl_c_quit_hint: false, + status: None, + queued_user_messages: QueuedUserMessages::new(), + esc_backtrack_hint: false, + animations_enabled, + context_window_percent: None, + context_window_used_tokens: None, + } + } + + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { + self.status.as_ref() + } + + #[cfg(test)] + pub(crate) fn context_window_percent(&self) -> Option { + self.context_window_percent + } + + #[cfg(test)] + pub(crate) fn context_window_used_tokens(&self) -> Option { + self.context_window_used_tokens + } + + fn active_view(&self) -> Option<&dyn BottomPaneView> { + self.view_stack.last().map(std::convert::AsRef::as_ref) + } + + fn push_view(&mut self, view: Box) { + self.view_stack.push(view); + self.request_redraw(); + } + + /// Forward a key event to the active view or the composer. + pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { + // 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 + } else { + // If a task is running and a status line is visible, allow Esc to + // send an interrupt even while the composer has focus. + if matches!(key_event.code, crossterm::event::KeyCode::Esc) + && self.is_task_running + && let Some(status) = &self.status + { + // Send Op::Interrupt + status.interrupt(); + self.request_redraw(); + return InputResult::None; + } + let (input_result, needs_redraw) = self.composer.handle_key_event(key_event); + if needs_redraw { + self.request_redraw(); + } + if self.composer.is_in_paste_burst() { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); + } + input_result + } + } + + /// 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 { + 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(); + } + event + } else if self.composer_is_empty() { + CancellationEvent::NotHandled + } else { + self.view_stack.pop(); + self.clear_composer_for_ctrl_c(); + self.show_ctrl_c_quit_hint(); + CancellationEvent::Handled + } + } + + pub fn handle_paste(&mut self, pasted: String) { + 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(); + } + if needs_redraw { + self.request_redraw(); + } + } else { + let needs_redraw = self.composer.handle_paste(pasted); + if needs_redraw { + self.request_redraw(); + } + } + } + + pub(crate) fn insert_str(&mut self, text: &str) { + self.composer.insert_str(text); + self.request_redraw(); + } + + /// Replace the composer text with `text`. + pub(crate) fn set_composer_text(&mut self, text: String) { + self.composer.set_text_content(text); + self.request_redraw(); + } + + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { + self.composer.clear_for_ctrl_c(); + self.request_redraw(); + } + + /// Get the current composer text (for tests and programmatic checks). + pub(crate) fn composer_text(&self) -> String { + self.composer.current_text() + } + + /// Update the animated header shown to the left of the brackets in the + /// status indicator (defaults to "Working"). No-ops if the status + /// indicator is not active. + pub(crate) fn update_status_header(&mut self, header: String) { + if let Some(status) = self.status.as_mut() { + status.update_header(header); + self.request_redraw(); + } + } + + pub(crate) fn show_ctrl_c_quit_hint(&mut self) { + self.ctrl_c_quit_hint = true; + self.composer + .set_ctrl_c_quit_hint(true, self.has_input_focus); + self.request_redraw(); + } + + pub(crate) fn clear_ctrl_c_quit_hint(&mut self) { + if self.ctrl_c_quit_hint { + self.ctrl_c_quit_hint = false; + self.composer + .set_ctrl_c_quit_hint(false, self.has_input_focus); + self.request_redraw(); + } + } + + #[cfg(test)] + pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool { + self.ctrl_c_quit_hint + } + + #[cfg(test)] + pub(crate) fn status_indicator_visible(&self) -> bool { + self.status.is_some() + } + + pub(crate) fn show_esc_backtrack_hint(&mut self) { + self.esc_backtrack_hint = true; + self.composer.set_esc_backtrack_hint(true); + self.request_redraw(); + } + + pub(crate) fn clear_esc_backtrack_hint(&mut self) { + if self.esc_backtrack_hint { + self.esc_backtrack_hint = false; + self.composer.set_esc_backtrack_hint(false); + self.request_redraw(); + } + } + + // esc_backtrack_hint_visible removed; hints are controlled internally. + + pub fn set_task_running(&mut self, running: bool) { + let was_running = self.is_task_running; + self.is_task_running = running; + self.composer.set_task_running(running); + + if running { + if !was_running { + if self.status.is_none() { + self.status = Some(StatusIndicatorWidget::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + self.animations_enabled, + )); + } + if let Some(status) = self.status.as_mut() { + status.set_interrupt_hint_visible(true); + } + self.request_redraw(); + } + } else { + // Hide the status indicator when a task completes, but keep other modal views. + self.hide_status_indicator(); + } + } + + /// Hide the status indicator while leaving task-running state untouched. + pub(crate) fn hide_status_indicator(&mut self) { + if self.status.take().is_some() { + self.request_redraw(); + } + } + + pub(crate) fn ensure_status_indicator(&mut self) { + if self.status.is_none() { + self.status = Some(StatusIndicatorWidget::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + self.animations_enabled, + )); + self.request_redraw(); + } + } + + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + if let Some(status) = self.status.as_mut() { + status.set_interrupt_hint_visible(visible); + self.request_redraw(); + } + } + + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { + if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + { + return; + } + + self.context_window_percent = percent; + self.context_window_used_tokens = used_tokens; + self.composer + .set_context_window(percent, self.context_window_used_tokens); + self.request_redraw(); + } + + /// 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.push_view(Box::new(view)); + } + + /// Update the queued messages preview shown above the composer. + pub(crate) fn set_queued_user_messages(&mut self, queued: Vec) { + self.queued_user_messages.messages = queued; + self.request_redraw(); + } + + /// Update custom prompts available for the slash popup. + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { + self.composer.set_custom_prompts(prompts); + self.request_redraw(); + } + + pub(crate) fn composer_is_empty(&self) -> bool { + self.composer.is_empty() + } + + pub(crate) fn is_task_running(&self) -> bool { + self.is_task_running + } + + /// Return true when the pane is in the regular composer state without any + /// 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.view_stack.is_empty() && !self.composer.popup_active() + } + + pub(crate) fn show_view(&mut self, view: Box) { + self.push_view(view); + } + + /// Called when the agent requests user approval. + pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { + let request = if let Some(view) = self.view_stack.last_mut() { + match view.try_consume_approval_request(request) { + Some(request) => request, + None => { + self.request_redraw(); + return; + } + } + } else { + request + }; + + // Otherwise create a new approval modal overlay. + let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone()); + self.pause_status_timer_for_modal(); + self.push_view(Box::new(modal)); + } + + fn on_active_view_complete(&mut self) { + self.resume_status_timer_after_modal(); + } + + fn pause_status_timer_for_modal(&mut self) { + if let Some(status) = self.status.as_mut() { + status.pause_timer(); + } + } + + fn resume_status_timer_after_modal(&mut self) { + if let Some(status) = self.status.as_mut() { + status.resume_timer(); + } + } + + /// Height (terminal rows) required by the current bottom pane. + pub(crate) fn request_redraw(&self) { + self.frame_requester.schedule_frame(); + } + + pub(crate) fn request_redraw_in(&self, dur: Duration) { + self.frame_requester.schedule_frame_in(dur); + } + + // --- History helpers --- + + pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { + self.composer.set_history_metadata(log_id, entry_count); + } + + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + self.composer.flush_paste_burst_if_due() + } + + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.composer.is_in_paste_burst() + } + + pub(crate) fn on_history_entry_response( + &mut self, + log_id: u64, + offset: usize, + entry: Option, + ) { + let updated = self + .composer + .on_history_entry_response(log_id, offset, entry); + + if updated { + self.request_redraw(); + } + } + + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + self.composer.on_file_search_result(query, matches); + self.request_redraw(); + } + + pub(crate) fn attach_image( + &mut self, + path: PathBuf, + width: u32, + height: u32, + format_label: &str, + ) { + if self.view_stack.is_empty() { + self.composer + .attach_image(path, width, height, format_label); + self.request_redraw(); + } + } + + pub(crate) fn take_recent_submission_images(&mut self) -> Vec { + self.composer.take_recent_submission_images() + } + + fn as_renderable(&'_ self) -> RenderableItem<'_> { + if let Some(view) = self.active_view() { + RenderableItem::Borrowed(view) + } else { + let mut flex = FlexRenderable::new(); + if let Some(status) = &self.status { + flex.push(0, RenderableItem::Borrowed(status)); + } + flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); + if self.status.is_some() || !self.queued_user_messages.messages.is_empty() { + flex.push(0, RenderableItem::Owned("".into())); + } + let mut flex2 = FlexRenderable::new(); + flex2.push(1, RenderableItem::Owned(flex.into())); + flex2.push(0, RenderableItem::Borrowed(&self.composer)); + RenderableItem::Owned(Box::new(flex2)) + } + } +} + +impl Renderable for BottomPane { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_renderable().cursor_pos(area) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + fn snapshot_buffer(buf: &Buffer) -> String { + let mut lines = Vec::new(); + for y in 0..buf.area().height { + let mut row = String::new(); + for x in 0..buf.area().width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(row); + } + lines.join("\n") + } + + fn render_snapshot(pane: &BottomPane, area: Rect) -> String { + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + snapshot_buffer(&buf) + } + + fn exec_request() -> ApprovalRequest { + ApprovalRequest::Exec { + id: "1".to_string(), + command: vec!["echo".into(), "ok".into()], + reason: None, + proposed_execpolicy_amendment: None, + } + } + + #[test] + fn ctrl_c_on_modal_consumes_and_shows_quit_hint() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + pane.push_approval_request(exec_request(), &features); + assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); + assert!(pane.ctrl_c_quit_hint_visible()); + assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); + } + + // live ring removed; related tests deleted. + + #[test] + fn overlay_not_shown_above_approval_modal() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Create an approval modal (active view). + pane.push_approval_request(exec_request(), &features); + + // Render and verify the top row does not include an overlay. + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + + let mut r0 = String::new(); + for x in 0..area.width { + r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + !r0.contains("Working"), + "overlay should not render above modal" + ); + } + + #[test] + fn composer_shown_after_denied_while_task_running() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Start a running task so the status indicator is active above the composer. + pane.set_task_running(true); + + // Push an approval modal (e.g., command approval) which should hide the status view. + pane.push_approval_request(exec_request(), &features); + + // Simulate pressing 'n' (No) on the modal. + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + + // After denial, since the task is still running, the status indicator should be + // visible above the composer. The modal should be gone. + assert!( + pane.view_stack.is_empty(), + "no active modal view after denial" + ); + + // Render and ensure the top row includes the Working header and a composer line below. + // Give the animation thread a moment to tick. + std::thread::sleep(Duration::from_millis(120)); + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + let mut row0 = String::new(); + for x in 0..area.width { + row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + row0.contains("Working"), + "expected Working header after denial on row 0: {row0:?}" + ); + + // Composer placeholder should be visible somewhere below. + let mut found_composer = false; + for y in 1..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("Ask Codex") { + found_composer = true; + break; + } + } + assert!( + found_composer, + "expected composer visible under status line" + ); + } + + #[test] + fn status_indicator_visible_during_command_execution() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Begin a task: show initial status. + pane.set_task_running(true); + + // Use a height that allows the status line to be visible above the composer. + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + + let bufs = snapshot_buffer(&buf); + assert!(bufs.contains("• Working"), "expected Working header"); + } + + #[test] + fn status_and_composer_fill_height_without_bottom_padding() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Activate spinner (status view replaces composer) with no live ring. + pane.set_task_running(true); + + // Use height == desired_height; expect spacer + status + composer rows without trailing padding. + let height = pane.desired_height(30); + assert!( + height >= 3, + "expected at least 3 rows to render spacer, status, and composer; got {height}" + ); + let area = Rect::new(0, 0, 30, height); + assert_snapshot!( + "status_and_composer_fill_height_without_bottom_padding", + render_snapshot(&pane, area) + ); + } + + #[test] + fn queued_messages_visible_when_status_hidden_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.hide_status_indicator(); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "queued_messages_visible_when_status_hidden_snapshot", + render_snapshot(&pane, area) + ); + } + + #[test] + fn status_and_queued_messages_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/paste_burst.rs b/codex-rs/tui2/src/bottom_pane/paste_burst.rs new file mode 100644 index 00000000000..49377cb21c5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/paste_burst.rs @@ -0,0 +1,267 @@ +use std::time::Duration; +use std::time::Instant; + +// Heuristic thresholds for detecting paste-like input bursts. +// Detect quickly to avoid showing typed prefix before paste is recognized +const PASTE_BURST_MIN_CHARS: u16 = 3; +const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8); +const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120); + +#[derive(Default)] +pub(crate) struct PasteBurst { + last_plain_char_time: Option, + consecutive_plain_char_burst: u16, + burst_window_until: Option, + buffer: String, + active: bool, + // Hold first fast char briefly to avoid rendering flicker + pending_first_char: Option<(char, Instant)>, +} + +pub(crate) enum CharDecision { + /// Start buffering and retroactively capture some already-inserted chars. + BeginBuffer { retro_chars: u16 }, + /// We are currently buffering; append the current char into the buffer. + BufferAppend, + /// Do not insert/render this char yet; temporarily save the first fast + /// char while we wait to see if a paste-like burst follows. + RetainFirstChar, + /// Begin buffering using the previously saved first char (no retro grab needed). + BeginBufferFromPending, +} + +pub(crate) struct RetroGrab { + pub start_byte: usize, + pub grabbed: String, +} + +pub(crate) enum FlushResult { + Paste(String), + Typed(char), + None, +} + +impl PasteBurst { + /// Recommended delay to wait between simulated keypresses (or before + /// scheduling a UI tick) so that a pending fast keystroke is flushed + /// out of the burst detector as normal typed input. + /// + /// Primarily used by tests and by the TUI to reliably cross the + /// paste-burst timing threshold. + pub fn recommended_flush_delay() -> Duration { + PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1) + } + + /// Entry point: decide how to treat a plain char with current timing. + pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision { + match self.last_plain_char_time { + Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => { + self.consecutive_plain_char_burst = + self.consecutive_plain_char_burst.saturating_add(1) + } + _ => self.consecutive_plain_char_burst = 1, + } + self.last_plain_char_time = Some(now); + + if self.active { + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return CharDecision::BufferAppend; + } + + // If we already held a first char and receive a second fast char, + // start buffering without retro-grabbing (we never rendered the first). + if let Some((held, held_at)) = self.pending_first_char + && now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL + { + self.active = true; + // take() to clear pending; we already captured the held char above + let _ = self.pending_first_char.take(); + self.buffer.push(held); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return CharDecision::BeginBufferFromPending; + } + + if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { + return CharDecision::BeginBuffer { + retro_chars: self.consecutive_plain_char_burst.saturating_sub(1), + }; + } + + // Save the first fast char very briefly to see if a burst follows. + self.pending_first_char = Some((ch, now)); + CharDecision::RetainFirstChar + } + + /// Flush the buffered burst if the inter-key timeout has elapsed. + /// + /// Returns Some(String) when either: + /// - We were actively buffering paste-like input and the buffer is now + /// emitted as a single pasted string; or + /// - We had saved a single fast first-char with no subsequent burst and we + /// now emit that char as normal typed input. + /// + /// Returns None if the timeout has not elapsed or there is nothing to flush. + pub fn flush_if_due(&mut self, now: Instant) -> FlushResult { + let timed_out = self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL); + if timed_out && self.is_active_internal() { + self.active = false; + let out = std::mem::take(&mut self.buffer); + FlushResult::Paste(out) + } else if timed_out { + // If we were saving a single fast char and no burst followed, + // flush it as normal typed input. + if let Some((ch, _at)) = self.pending_first_char.take() { + FlushResult::Typed(ch) + } else { + FlushResult::None + } + } else { + FlushResult::None + } + } + + /// While bursting: accumulate a newline into the buffer instead of + /// submitting the textarea. + /// + /// Returns true if a newline was appended (we are in a burst context), + /// false otherwise. + pub fn append_newline_if_active(&mut self, now: Instant) -> bool { + if self.is_active() { + self.buffer.push('\n'); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + true + } else { + false + } + } + + /// Decide if Enter should insert a newline (burst context) vs submit. + pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool { + let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until); + self.is_active() || in_burst_window + } + + /// Keep the burst window alive. + pub fn extend_window(&mut self, now: Instant) { + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Begin buffering with retroactively grabbed text. + pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) { + if !grabbed.is_empty() { + self.buffer.push_str(&grabbed); + } + self.active = true; + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Append a char into the burst buffer. + pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) { + self.buffer.push(ch); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Try to append a char into the burst buffer only if a burst is already active. + /// + /// Returns true when the char was captured into the existing burst, false otherwise. + pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool { + if self.active || !self.buffer.is_empty() { + self.append_char_to_buffer(ch, now); + true + } else { + false + } + } + + /// Decide whether to begin buffering by retroactively capturing recent + /// chars from the slice before the cursor. + /// + /// Heuristic: if the retro-grabbed slice contains any whitespace or is + /// sufficiently long (>= 16 characters), treat it as paste-like to avoid + /// rendering the typed prefix momentarily before the paste is recognized. + /// This favors responsiveness and prevents flicker for typical pastes + /// (URLs, file paths, multiline text) while not triggering on short words. + /// + /// Returns Some(RetroGrab) with the start byte and grabbed text when we + /// decide to buffer retroactively; otherwise None. + pub fn decide_begin_buffer( + &mut self, + now: Instant, + before: &str, + retro_chars: usize, + ) -> Option { + let start_byte = retro_start_index(before, retro_chars); + let grabbed = before[start_byte..].to_string(); + let looks_pastey = + 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); + Some(RetroGrab { + start_byte, + grabbed, + }) + } else { + None + } + } + + /// Before applying modified/non-char input: flush buffered burst immediately. + pub fn flush_before_modified_input(&mut self) -> Option { + if !self.is_active() { + return None; + } + self.active = false; + let mut out = std::mem::take(&mut self.buffer); + if let Some((ch, _at)) = self.pending_first_char.take() { + out.push(ch); + } + Some(out) + } + + /// Clear only the timing window and any pending first-char. + /// + /// Does not emit or clear the buffered text itself; callers should have + /// already flushed (if needed) via one of the flush methods above. + pub fn clear_window_after_non_char(&mut self) { + self.consecutive_plain_char_burst = 0; + self.last_plain_char_time = None; + self.burst_window_until = None; + self.active = false; + self.pending_first_char = None; + } + + /// Returns true if we are in any paste-burst related transient state + /// (actively buffering, have a non-empty buffer, or have saved the first + /// fast char while waiting for a potential burst). + pub fn is_active(&self) -> bool { + self.is_active_internal() || self.pending_first_char.is_some() + } + + fn is_active_internal(&self) -> bool { + self.active || !self.buffer.is_empty() + } + + pub fn clear_after_explicit_paste(&mut self) { + self.last_plain_char_time = None; + self.consecutive_plain_char_burst = 0; + self.burst_window_until = None; + self.active = false; + self.buffer.clear(); + self.pending_first_char = None; + } +} + +pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize { + if retro_chars == 0 { + return before.len(); + } + before + .char_indices() + .rev() + .nth(retro_chars.saturating_sub(1)) + .map(|(idx, _)| idx) + .unwrap_or(0) +} diff --git a/codex-rs/tui2/src/bottom_pane/popup_consts.rs b/codex-rs/tui2/src/bottom_pane/popup_consts.rs new file mode 100644 index 00000000000..2cabe389b1b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/popup_consts.rs @@ -0,0 +1,21 @@ +//! Shared popup-related constants for bottom pane widgets. + +use crossterm::event::KeyCode; +use ratatui::text::Line; + +use crate::key_hint; + +/// 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) fn standard_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to confirm or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) +} diff --git a/codex-rs/tui2/src/bottom_pane/prompt_args.rs b/codex-rs/tui2/src/bottom_pane/prompt_args.rs new file mode 100644 index 00000000000..48c3cedfab8 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/prompt_args.rs @@ -0,0 +1,406 @@ +use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; +use lazy_static::lazy_static; +use regex_lite::Regex; +use shlex::Shlex; +use std::collections::HashMap; +use std::collections::HashSet; + +lazy_static! { + static ref PROMPT_ARG_REGEX: Regex = + Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort()); +} + +#[derive(Debug)] +pub enum PromptArgsError { + MissingAssignment { token: String }, + MissingKey { token: String }, +} + +impl PromptArgsError { + fn describe(&self, command: &str) -> String { + match self { + PromptArgsError::MissingAssignment { token } => format!( + "Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces." + ), + PromptArgsError::MissingKey { token } => { + format!("Could not parse {command}: expected a name before '=' in '{token}'.") + } + } + } +} + +#[derive(Debug)] +pub enum PromptExpansionError { + Args { + command: String, + error: PromptArgsError, + }, + MissingArgs { + command: String, + missing: Vec, + }, +} + +impl PromptExpansionError { + pub fn user_message(&self) -> String { + match self { + PromptExpansionError::Args { command, error } => error.describe(command), + PromptExpansionError::MissingArgs { command, missing } => { + let list = missing.join(", "); + format!( + "Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)." + ) + } + } + } +} + +/// Parse a first-line slash command of the form `/name `. +/// Returns `(name, rest_after_name)` if the line begins with `/` and contains +/// a non-empty name; otherwise returns `None`. +pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> { + let stripped = line.strip_prefix('/')?; + let mut name_end = stripped.len(); + for (idx, ch) in stripped.char_indices() { + if ch.is_whitespace() { + name_end = idx; + break; + } + } + let name = &stripped[..name_end]; + if name.is_empty() { + return None; + } + let rest = stripped[name_end..].trim_start(); + Some((name, rest)) +} + +/// Parse positional arguments using shlex semantics (supports quoted tokens). +pub fn parse_positional_args(rest: &str) -> Vec { + Shlex::new(rest).collect() +} + +/// Extracts the unique placeholder variable names from a prompt template. +/// +/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*` +/// (for example `$USER`). The function returns the variable names without +/// the leading `$`, de-duplicated and in the order of first appearance. +pub fn prompt_argument_names(content: &str) -> Vec { + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for m in PROMPT_ARG_REGEX.find_iter(content) { + if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' { + continue; + } + let name = &content[m.start() + 1..m.end()]; + // Exclude special positional aggregate token from named args. + if name == "ARGUMENTS" { + continue; + } + let name = name.to_string(); + if seen.insert(name.clone()) { + names.push(name); + } + } + names +} + +/// Parses the `key=value` pairs that follow a custom prompt name. +/// +/// The input is split using shlex rules, so quoted values are supported +/// (for example `USER="Alice Smith"`). The function returns a map of parsed +/// arguments, or an error if a token is missing `=` or if the key is empty. +pub fn parse_prompt_inputs(rest: &str) -> Result, PromptArgsError> { + let mut map = HashMap::new(); + if rest.trim().is_empty() { + return Ok(map); + } + + for token in Shlex::new(rest) { + let Some((key, value)) = token.split_once('=') else { + return Err(PromptArgsError::MissingAssignment { token }); + }; + if key.is_empty() { + return Err(PromptArgsError::MissingKey { token }); + } + map.insert(key.to_string(), value.to_string()); + } + Ok(map) +} + +/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt. +/// +/// If the text does not start with `/prompts:`, or if no prompt named `name` exists, +/// the function returns `Ok(None)`. On success it returns +/// `Ok(Some(expanded))`; otherwise it returns a descriptive error. +pub fn expand_custom_prompt( + text: &str, + custom_prompts: &[CustomPrompt], +) -> Result, PromptExpansionError> { + let Some((name, rest)) = parse_slash_name(text) else { + return Ok(None); + }; + + // Only handle custom prompts when using the explicit prompts prefix with a colon. + let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Ok(None); + }; + + let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) { + Some(prompt) => prompt, + None => return Ok(None), + }; + // If there are named placeholders, expect key=value inputs. + let required = prompt_argument_names(&prompt.content); + if !required.is_empty() { + let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args { + command: format!("/{name}"), + error, + })?; + let missing: Vec = required + .into_iter() + .filter(|k| !inputs.contains_key(k)) + .collect(); + if !missing.is_empty() { + return Err(PromptExpansionError::MissingArgs { + command: format!("/{name}"), + missing, + }); + } + let content = &prompt.content; + let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| { + if let Some(matched) = caps.get(0) + && matched.start() > 0 + && content.as_bytes()[matched.start() - 1] == b'$' + { + return matched.as_str().to_string(); + } + let whole = &caps[0]; + let key = &whole[1..]; + inputs + .get(key) + .cloned() + .unwrap_or_else(|| whole.to_string()) + }); + return Ok(Some(replaced.into_owned())); + } + + // Otherwise, treat it as numeric/positional placeholder prompt (or none). + let pos_args: Vec = Shlex::new(rest).collect(); + let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); + Ok(Some(expanded)) +} + +/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. +pub fn prompt_has_numeric_placeholders(content: &str) -> bool { + if content.contains("$ARGUMENTS") { + return true; + } + let bytes = content.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'$' { + let b1 = bytes[i + 1]; + if (b'1'..=b'9').contains(&b1) { + return true; + } + } + i += 1; + } + false +} + +/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name. +/// Returns empty when the command name does not match or when there are no args. +pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec { + let trimmed = line.trim_start(); + let Some(rest) = trimmed.strip_prefix('/') else { + return Vec::new(); + }; + // Require the explicit prompts prefix for custom prompt invocations. + let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Vec::new(); + }; + let mut parts = after_prefix.splitn(2, char::is_whitespace); + let cmd = parts.next().unwrap_or(""); + if cmd != prompt_name { + return Vec::new(); + } + let args_str = parts.next().unwrap_or("").trim(); + if args_str.is_empty() { + return Vec::new(); + } + parse_positional_args(args_str) +} + +/// If the prompt only uses numeric placeholders and the first line contains +/// positional args for it, expand and return Some(expanded); otherwise None. +pub fn expand_if_numeric_with_positional_args( + prompt: &CustomPrompt, + first_line: &str, +) -> Option { + if !prompt_argument_names(&prompt.content).is_empty() { + return None; + } + if !prompt_has_numeric_placeholders(&prompt.content) { + return None; + } + let args = extract_positional_args_for_prompt_line(first_line, &prompt.name); + if args.is_empty() { + return None; + } + Some(expand_numeric_placeholders(&prompt.content, &args)) +} + +/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`. +pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { + let mut out = String::with_capacity(content.len()); + let mut i = 0; + let mut cached_joined_args: Option = None; + while let Some(off) = content[i..].find('$') { + let j = i + off; + out.push_str(&content[i..j]); + let rest = &content[j..]; + let bytes = rest.as_bytes(); + if bytes.len() >= 2 { + match bytes[1] { + b'$' => { + out.push_str("$$"); + i = j + 2; + continue; + } + b'1'..=b'9' => { + let idx = (bytes[1] - b'1') as usize; + if let Some(val) = args.get(idx) { + out.push_str(val); + } + i = j + 2; + continue; + } + _ => {} + } + } + if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { + if !args.is_empty() { + let joined = cached_joined_args.get_or_insert_with(|| args.join(" ")); + out.push_str(joined); + } + i = j + 1 + "ARGUMENTS".len(); + continue; + } + out.push('$'); + i = j + 1; + } + out.push_str(&content[i..]); + out +} + +/// Constructs a command text for a custom prompt with arguments. +/// Returns the text and the cursor position (inside the first double quote). +pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) { + let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); + let mut cursor: usize = text.len(); + for (i, arg) in args.iter().enumerate() { + text.push_str(format!(" {arg}=\"\"").as_str()); + if i == 0 { + cursor = text.len() - 1; // inside first "" + } + } + (text, cursor) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_arguments_basic() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]; + + let out = + expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap(); + assert_eq!(out, Some("Review Alice changes on main".to_string())); + } + + #[test] + fn quoted_values_ok() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Pair $USER with $BRANCH".to_string(), + description: None, + argument_hint: None, + }]; + + let out = expand_custom_prompt( + "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", + &prompts, + ) + .unwrap(); + assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string())); + } + + #[test] + fn invalid_arg_token_reports_error() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes".to_string(), + description: None, + argument_hint: None, + }]; + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts) + .unwrap_err() + .user_message(); + assert!(err.contains("expected key=value")); + } + + #[test] + fn missing_required_args_reports_error() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]; + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts) + .unwrap_err() + .user_message(); + assert!(err.to_lowercase().contains("missing required args")); + assert!(err.contains("BRANCH")); + } + + #[test] + fn escaped_placeholder_is_ignored() { + assert_eq!( + prompt_argument_names("literal $$USER"), + Vec::::new() + ); + assert_eq!( + prompt_argument_names("literal $$USER and $REAL"), + vec!["REAL".to_string()] + ); + } + + #[test] + fn escaped_placeholder_remains_literal() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "literal $$USER".to_string(), + description: None, + argument_hint: None, + }]; + + let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap(); + assert_eq!(out, Some("literal $$USER".to_string())); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs new file mode 100644 index 00000000000..ae33aeada47 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs @@ -0,0 +1,157 @@ +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::key_hint; +use crate::render::renderable::Renderable; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +/// Widget that displays a list of user messages queued while a turn is in progress. +pub(crate) struct QueuedUserMessages { + pub messages: Vec, +} + +impl QueuedUserMessages { + pub(crate) fn new() -> Self { + Self { + messages: Vec::new(), + } + } + + fn as_renderable(&self, width: u16) -> Box { + if self.messages.is_empty() || width < 4 { + return Box::new(()); + } + + let mut lines = vec![]; + + for message in &self.messages { + let wrapped = word_wrap_lines( + message.lines().map(|line| line.dim().italic()), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + let len = wrapped.len(); + for line in wrapped.into_iter().take(3) { + lines.push(line); + } + if len > 3 { + lines.push(Line::from(" …".dim().italic())); + } + } + + lines.push( + Line::from(vec![ + " ".into(), + key_hint::alt(KeyCode::Up).into(), + " edit".into(), + ]) + .dim(), + ); + + Paragraph::new(lines).into() + } +} + +impl Renderable for QueuedUserMessages { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + self.as_renderable(area.width).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable(width).desired_height(width) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + + #[test] + fn desired_height_empty() { + let queue = QueuedUserMessages::new(); + assert_eq!(queue.desired_height(40), 0); + } + + #[test] + fn desired_height_one_message() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + assert_eq!(queue.desired_height(40), 2); + } + + #[test] + fn render_one_message() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_one_message", format!("{buf:?}")); + } + + #[test] + fn render_two_messages() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + queue.messages.push("This is another message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_two_messages", format!("{buf:?}")); + } + + #[test] + fn render_more_than_three_messages() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + queue.messages.push("This is another message".to_string()); + queue.messages.push("This is a third message".to_string()); + queue.messages.push("This is a fourth message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_more_than_three_messages", format!("{buf:?}")); + } + + #[test] + fn render_wrapped_message() { + let mut queue = QueuedUserMessages::new(); + queue + .messages + .push("This is a longer message that should be wrapped".to_string()); + queue.messages.push("This is another message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_wrapped_message", format!("{buf:?}")); + } + + #[test] + fn render_many_line_message() { + let mut queue = QueuedUserMessages::new(); + queue + .messages + .push("This is\na message\nwith many\nlines".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_many_line_message", format!("{buf:?}")); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/scroll_state.rs b/codex-rs/tui2/src/bottom_pane/scroll_state.rs new file mode 100644 index 00000000000..a9728d1a0db --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/scroll_state.rs @@ -0,0 +1,115 @@ +/// Generic scroll/selection state for a vertical list menu. +/// +/// Encapsulates the common behavior of a selectable list that supports: +/// - Optional selection (None when list is empty) +/// - Wrap-around navigation on Up/Down +/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct ScrollState { + pub selected_idx: Option, + pub scroll_top: usize, +} + +impl ScrollState { + pub fn new() -> Self { + Self { + selected_idx: None, + scroll_top: 0, + } + } + + /// Reset selection and scroll. + pub fn reset(&mut self) { + self.selected_idx = None; + self.scroll_top = 0; + } + + /// Clamp selection to be within the [0, len-1] range, or None when empty. + pub fn clamp_selection(&mut self, len: usize) { + self.selected_idx = match len { + 0 => None, + _ => Some(self.selected_idx.unwrap_or(0).min(len - 1)), + }; + if len == 0 { + self.scroll_top = 0; + } + } + + /// Move selection up by one, wrapping to the bottom when necessary. + pub fn move_up_wrap(&mut self, len: usize) { + if len == 0 { + self.selected_idx = None; + self.scroll_top = 0; + return; + } + self.selected_idx = Some(match self.selected_idx { + Some(idx) if idx > 0 => idx - 1, + Some(_) => len - 1, + None => 0, + }); + } + + /// Move selection down by one, wrapping to the top when necessary. + pub fn move_down_wrap(&mut self, len: usize) { + if len == 0 { + self.selected_idx = None; + self.scroll_top = 0; + return; + } + self.selected_idx = Some(match self.selected_idx { + Some(idx) if idx + 1 < len => idx + 1, + _ => 0, + }); + } + + /// Adjust `scroll_top` so that the current `selected_idx` is visible within + /// the window of `visible_rows`. + pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) { + if len == 0 || visible_rows == 0 { + self.scroll_top = 0; + return; + } + if let Some(sel) = self.selected_idx { + if sel < self.scroll_top { + self.scroll_top = sel; + } else { + let bottom = self.scroll_top + visible_rows - 1; + if sel > bottom { + self.scroll_top = sel + 1 - visible_rows; + } + } + } else { + self.scroll_top = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::ScrollState; + + #[test] + fn wrap_navigation_and_visibility() { + let mut s = ScrollState::new(); + let len = 10; + let vis = 5; + + s.clamp_selection(len); + assert_eq!(s.selected_idx, Some(0)); + s.ensure_visible(len, vis); + assert_eq!(s.scroll_top, 0); + + s.move_up_wrap(len); + s.ensure_visible(len, vis); + assert_eq!(s.selected_idx, Some(len - 1)); + match s.selected_idx { + Some(sel) => assert!(s.scroll_top <= sel), + None => panic!("expected Some(selected_idx) after wrap"), + } + + s.move_down_wrap(len); + s.ensure_visible(len, vis); + assert_eq!(s.selected_idx, Some(0)); + assert_eq!(s.scroll_top, 0); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs new file mode 100644 index 00000000000..5107ab0ca91 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs @@ -0,0 +1,269 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +// Note: Table-based layout previously used Constraint; the manual renderer +// below no longer requires it. +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthChar; + +use crate::key_hint::KeyBinding; + +use super::scroll_state::ScrollState; + +/// A generic representation of a display row for selection popups. +pub(crate) struct GenericDisplayRow { + pub name: String, + pub display_shortcut: Option, + pub match_indices: Option>, // indices to bold (char positions) + pub description: Option, // optional grey text after the name + pub wrap_indent: Option, // optional indent for wrapped lines +} + +/// Compute a shared description-column start based on the widest visible name +/// plus two spaces of padding. Ensures at least one column is left for the +/// description. +fn compute_desc_col( + rows_all: &[GenericDisplayRow], + start_idx: usize, + visible_items: usize, + content_width: u16, +) -> usize { + let visible_range = start_idx..(start_idx + visible_items); + let max_name_width = rows_all + .iter() + .enumerate() + .filter(|(i, _)| visible_range.contains(i)) + .map(|(_, r)| Line::from(r.name.clone()).width()) + .max() + .unwrap_or(0); + let mut desc_col = max_name_width.saturating_add(2); + if (desc_col as u16) >= content_width { + desc_col = content_width.saturating_sub(1) as usize; + } + desc_col +} + +/// Determine how many spaces to indent wrapped lines for a row. +fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { + let max_indent = max_width.saturating_sub(1) as usize; + let indent = row.wrap_indent.unwrap_or_else(|| { + if row.description.is_some() { + desc_col + } else { + 0 + } + }); + indent.min(max_indent) +} + +/// Build the full display line for a row with the description padded to start +/// 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 = row + .description + .as_ref() + .map(|_| desc_col.saturating_sub(2)) + .unwrap_or(usize::MAX); + + 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); + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { + truncated = true; + break; + } + used_width = next_width; + + if idx_iter.peek().is_some_and(|next| **next == char_idx) { + idx_iter.next(); + name_spans.push(ch.to_string().bold()); + } else { + name_spans.push(ch.to_string().into()); + } + } + } else { + for ch in row.name.chars() { + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { + truncated = true; + break; + } + used_width = next_width; + 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(); + let mut full_spans: Vec = name_spans; + if let Some(display_shortcut) = row.display_shortcut { + full_spans.push(" (".into()); + full_spans.push(display_shortcut.into()); + full_spans.push(")".into()); + } + if let Some(desc) = row.description.as_ref() { + let gap = desc_col.saturating_sub(this_name_width); + if gap > 0 { + full_spans.push(" ".repeat(gap).into()); + } + full_spans.push(desc.clone().dim()); + } + Line::from(full_spans) +} + +/// Render a list of rows using the provided ScrollState, with shared styling +/// and behavior for selection popups. +pub(crate) fn render_rows( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, +) { + if rows_all.is_empty() { + if area.height > 0 { + Line::from(empty_message.dim().italic()).render(area, buf); + } + return; + } + + // Determine which logical rows (items) are visible given the selection and + // the max_results clamp. Scrolling is still item-based for simplicity. + let visible_items = max_results + .min(rows_all.len()) + .min(area.height.max(1) as usize); + + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; + } + } + } + + let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width); + + // Render items, wrapping descriptions and aligning wrapped lines under the + // shared description column. Stop when we run out of vertical space. + let mut cur_y = area.y; + for (i, row) in rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + { + if cur_y >= area.y + area.height { + break; + } + + let mut full_line = build_full_line(row, desc_col); + if Some(i) == state.selected_idx { + // Match previous behavior: cyan + bold for the selected row. + // Reset the style first to avoid inheriting dim from keyboard shortcuts. + full_line.spans.iter_mut().for_each(|span| { + span.style = Style::default().fg(Color::Cyan).bold(); + }); + } + + // Wrap with subsequent indent aligned to the description column. + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + let continuation_indent = wrap_indent(row, desc_col, area.width); + let options = RtOptions::new(area.width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); + let wrapped = word_wrap_line(&full_line, options); + + // Render the wrapped lines. + for line in wrapped { + if cur_y >= area.y + area.height { + break; + } + line.render( + Rect { + x: area.x, + y: cur_y, + width: area.width, + height: 1, + }, + buf, + ); + cur_y = cur_y.saturating_add(1); + } + } +} + +/// Compute the number of terminal rows required to render up to `max_results` +/// items from `rows_all` given the current scroll/selection state and the +/// available `width`. Accounts for description wrapping and alignment so the +/// caller can allocate sufficient vertical space. +pub(crate) fn measure_rows_height( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, +) -> u16 { + if rows_all.is_empty() { + return 1; // placeholder "no matches" line + } + + let content_width = width.saturating_sub(1).max(1); + + let visible_items = max_results.min(rows_all.len()); + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; + } + } + } + + let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width); + + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + let mut total: u16 = 0; + for row in rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + .map(|(_, r)| r) + { + let full_line = build_full_line(row, desc_col); + let continuation_indent = wrap_indent(row, desc_col, content_width); + let opts = RtOptions::new(content_width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); + total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16); + } + total.max(1) +} diff --git a/codex-rs/tui2/src/bottom_pane/skill_popup.rs b/codex-rs/tui2/src/bottom_pane/skill_popup.rs new file mode 100644 index 00000000000..3e0f79f84bb --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/skill_popup.rs @@ -0,0 +1,142 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; +use crate::render::Insets; +use crate::render::RectExt; +use codex_common::fuzzy_match::fuzzy_match; +use codex_core::skills::model::SkillMetadata; + +pub(crate) struct SkillPopup { + query: String, + skills: Vec, + state: ScrollState, +} + +impl SkillPopup { + pub(crate) fn new(skills: Vec) -> Self { + Self { + query: String::new(), + skills, + state: ScrollState::new(), + } + } + + pub(crate) fn set_skills(&mut self, skills: Vec) { + self.skills = skills; + self.clamp_selection(); + } + + pub(crate) fn set_query(&mut self, query: &str) { + self.query = query.to_string(); + self.clamp_selection(); + } + + pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { + let rows = self.rows_from_matches(self.filtered()); + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) + } + + pub(crate) fn move_up(&mut self) { + let len = self.filtered_items().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn move_down(&mut self) { + let len = self.filtered_items().len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { + let matches = self.filtered_items(); + let idx = self.state.selected_idx?; + let skill_idx = matches.get(idx)?; + self.skills.get(*skill_idx) + } + + fn clamp_selection(&mut self) { + let len = self.filtered_items().len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn filtered_items(&self) -> Vec { + self.filtered().into_iter().map(|(idx, _, _)| idx).collect() + } + + fn rows_from_matches( + &self, + matches: Vec<(usize, Option>, i32)>, + ) -> Vec { + matches + .into_iter() + .map(|(idx, indices, _score)| { + let skill = &self.skills[idx]; + let slug = skill + .path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(&skill.name); + let name = format!("{} ({slug})", skill.name); + let description = skill.description.clone(); + GenericDisplayRow { + name, + match_indices: indices, + display_shortcut: None, + description: Some(description), + wrap_indent: None, + } + }) + .collect() + } + + fn filtered(&self) -> Vec<(usize, Option>, i32)> { + let filter = self.query.trim(); + let mut out: Vec<(usize, Option>, i32)> = Vec::new(); + + if filter.is_empty() { + for (idx, _skill) in self.skills.iter().enumerate() { + out.push((idx, None, 0)); + } + return out; + } + + for (idx, skill) in self.skills.iter().enumerate() { + if let Some((indices, score)) = fuzzy_match(&skill.name, filter) { + out.push((idx, Some(indices), score)); + } + } + + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = &self.skills[a.0].name; + let bn = &self.skills[b.0].name; + an.cmp(bn) + }) + }); + + out + } +} + +impl WidgetRef for SkillPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let rows = self.rows_from_matches(self.filtered()); + render_rows( + area.inset(Insets::tlbr(0, 2, 0, 0)), + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no skills", + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap new file mode 100644 index 00000000000..00821b7910d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap new file mode 100644 index 00000000000..1a34b29f924 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap new file mode 100644 index 00000000000..d323fda148b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap new file mode 100644 index 00000000000..d9395f2b055 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap new file mode 100644 index 00000000000..9e93b8d6833 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap new file mode 100644 index 00000000000..1d16779b01f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap new file mode 100644 index 00000000000..9e93b8d6833 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap new file mode 100644 index 00000000000..0aa72ca002d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› h " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap new file mode 100644 index 00000000000..1d16779b01f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap new file mode 100644 index 00000000000..178182bfd77 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -0,0 +1,16 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap new file mode 100644 index 00000000000..3b7711d75f0 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1005 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap new file mode 100644 index 00000000000..426afbec6ec --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap new file mode 100644 index 00000000000..dc66d149e47 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /mo " +" " +" /model choose what model and reasoning effort to use " +" /mention mention a file " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap new file mode 100644 index 00000000000..daedb3d8889 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,10 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /res " +" " +" " +" " +" /resume resume a saved chat " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap new file mode 100644 index 00000000000..8f669e1cb93 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› short " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap new file mode 100644 index 00000000000..f3c3a319bcd --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bad result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap new file mode 100644 index 00000000000..2ab262c229e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bug) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap new file mode 100644 index 00000000000..6bd68462029 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (good result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap new file mode 100644 index 00000000000..1ec33c54eee --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (other) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap new file mode 100644 index 00000000000..e31cf10f068 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 123K used · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap new file mode 100644 index 00000000000..157853e73d5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap new file mode 100644 index 00000000000..98bc87b38ee --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap new file mode 100644 index 00000000000..201bec4f629 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap new file mode 100644 index 00000000000..0bc46a989af --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap new file mode 100644 index 00000000000..2dd8738fe0c --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 72% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap new file mode 100644 index 00000000000..286acadd8b9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap new file mode 100644 index 00000000000..47508f32406 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -0,0 +1,8 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap new file mode 100644 index 00000000000..b46a229ad4c --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 80)" +--- + + Select Model and Effort + +› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning + quality and coding ability. + 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less + capable. + 3. gpt-4.1-codex Legacy model. Use when you need compatibility + with older automations. diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap new file mode 100644 index 00000000000..bcdc8a35615 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap @@ -0,0 +1,16 @@ +--- +source: tui2/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 24)" +--- + + Debug + +› 1. Item 1 + xxxxxxxxx + x + 2. Item 2 + xxxxxxxxx + x + 3. Item 3 + xxxxxxxxx + x diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap new file mode 100644 index 00000000000..2cc2578c56a --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -0,0 +1,12 @@ +--- +source: tui2/src/bottom_pane/list_selection_view.rs +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 + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap new file mode 100644 index 00000000000..88a5d14932f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -0,0 +1,11 @@ +--- +source: tui2/src/bottom_pane/list_selection_view.rs +expression: render_lines(&view) +--- + + Select Approval Mode + +› 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/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap new file mode 100644 index 00000000000..c715e81c9a9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ This is ", + " a message ", + " with many ", + " … ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap new file mode 100644 index 00000000000..1e88bfb5b19 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap @@ -0,0 +1,30 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ↳ This is a third message ", + " ↳ This is a fourth message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap new file mode 100644 index 00000000000..8160a886db5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 2 }, + content: [ + " ↳ Hello, world! ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap new file mode 100644 index 00000000000..9b1ef9e5c60 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap new file mode 100644 index 00000000000..f46cf990fa6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap new file mode 100644 index 00000000000..71504561db3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap new file mode 100644 index 00000000000..f6c157922a3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -0,0 +1,10 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interru + + +› Ask Codex to do anything + + 100% context left · ? for sh diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap new file mode 100644 index 00000000000..6ac4296833e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap new file mode 100644 index 00000000000..e4cc9ffefd5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap new file mode 100644 index 00000000000..53e0aee4cf9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap new file mode 100644 index 00000000000..49ffb0d4c8f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap new file mode 100644 index 00000000000..7ecc5bba719 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap new file mode 100644 index 00000000000..9cad17b8648 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap new file mode 100644 index 00000000000..2fce42cc26b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap new file mode 100644 index 00000000000..9cad17b8648 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap new file mode 100644 index 00000000000..67e616e917f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› h " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap new file mode 100644 index 00000000000..2fce42cc26b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap new file mode 100644 index 00000000000..3b6782d06d6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap new file mode 100644 index 00000000000..6b018021ece --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1005 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap new file mode 100644 index 00000000000..40098faee01 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap new file mode 100644 index 00000000000..661e82e3ad1 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /mo " +" " +" /model choose what model and reasoning effort to use " +" /mention mention a file " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap new file mode 100644 index 00000000000..df8ea36e638 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2385 +expression: terminal.backend() +--- +" " +"› /res " +" " +" " +" " +" /resume resume a saved chat " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap new file mode 100644 index 00000000000..498ed769366 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› short " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap new file mode 100644 index 00000000000..465f0f9c4f3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bad result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap new file mode 100644 index 00000000000..a0b5660135b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bug) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap new file mode 100644 index 00000000000..73074d61faa --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (good result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap new file mode 100644 index 00000000000..80e4ffeffe1 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (other) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap new file mode 100644 index 00000000000..bafa94b09de --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- + Do you want to upload logs before reporting issue? + + Logs may include the full conversation history of this Codex process + These logs are retained for 90 days and are used solely for troubles + + You can review the exact content of the logs before they’re uploaded + + + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + 3. Cancel diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap new file mode 100644 index 00000000000..a77ca5565b6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 123K used · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap new file mode 100644 index 00000000000..31a1b743b8e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap new file mode 100644 index 00000000000..9979372a1b9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap new file mode 100644 index 00000000000..b2333b025f6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap new file mode 100644 index 00000000000..20f9b178b4b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap new file mode 100644 index 00000000000..d05ac90a911 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 72% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap new file mode 100644 index 00000000000..c95a5dc0b3d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap new file mode 100644 index 00000000000..264515a6c2b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap new file mode 100644 index 00000000000..be81978c896 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 80)" +--- + + Select Model and Effort + +› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning + quality and coding ability. + 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less + capable. + 3. gpt-4.1-codex Legacy model. Use when you need compatibility + with older automations. diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap new file mode 100644 index 00000000000..3ce6a3c45ff --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 24)" +--- + + Debug + +› 1. Item 1 + xxxxxxxxx + x + 2. Item 2 + xxxxxxxxx + x + 3. Item 3 + xxxxxxxxx + x diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap new file mode 100644 index 00000000000..512f6bbca63 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +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 + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap new file mode 100644 index 00000000000..ddd0f90cd87 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: render_lines(&view) +--- + + Select Approval Mode + +› 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/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap new file mode 100644 index 00000000000..cf1f7248b32 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ This is ", + " a message ", + " with many ", + " … ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap new file mode 100644 index 00000000000..5e403e1bddf --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 2 }, + content: [ + " ↳ Hello, world! ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap new file mode 100644 index 00000000000..4484509695b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap new file mode 100644 index 00000000000..16d63612574 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap new file mode 100644 index 00000000000..d2afbf7dbd0 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ This is ", + " a message ", + " with many ", + " … ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap new file mode 100644 index 00000000000..9d7527d16fa --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ↳ This is a third message ", + " ↳ This is a fourth message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap new file mode 100644 index 00000000000..d47fa978634 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 2 }, + content: [ + " ↳ Hello, world! ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap new file mode 100644 index 00000000000..1f020fec64e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap new file mode 100644 index 00000000000..4f2917a6c42 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap new file mode 100644 index 00000000000..123a5eb3a3e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap new file mode 100644 index 00000000000..86e3da45730 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interru + + +› Ask Codex to do anything + + 100% context left · ? for sh diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap new file mode 100644 index 00000000000..27df671e4d3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap new file mode 100644 index 00000000000..52f96e8557a --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area1)" +--- +› Ask Codex to do a diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs new file mode 100644 index 00000000000..2fd415c7f65 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -0,0 +1,2015 @@ +use crate::key_hint::is_altgr; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::WidgetRef; +use std::cell::Ref; +use std::cell::RefCell; +use std::ops::Range; +use textwrap::Options; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; + +fn is_word_separator(ch: char) -> bool { + WORD_SEPARATORS.contains(ch) +} + +#[derive(Debug, Clone)] +struct TextElement { + range: Range, +} + +#[derive(Debug)] +pub(crate) struct TextArea { + text: String, + cursor_pos: usize, + wrap_cache: RefCell>, + preferred_col: Option, + elements: Vec, + kill_buffer: String, +} + +#[derive(Debug, Clone)] +struct WrapCache { + width: u16, + lines: Vec>, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct TextAreaState { + /// Index into wrapped lines of the first visible line. + scroll: u16, +} + +impl TextArea { + pub fn new() -> Self { + Self { + text: String::new(), + cursor_pos: 0, + wrap_cache: RefCell::new(None), + preferred_col: None, + elements: Vec::new(), + kill_buffer: String::new(), + } + } + + pub fn set_text(&mut self, text: &str) { + self.text = text.to_string(); + self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); + self.wrap_cache.replace(None); + self.preferred_col = None; + self.elements.clear(); + self.kill_buffer.clear(); + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn insert_str(&mut self, text: &str) { + self.insert_str_at(self.cursor_pos, text); + } + + pub fn insert_str_at(&mut self, pos: usize, text: &str) { + let pos = self.clamp_pos_for_insertion(pos); + self.text.insert_str(pos, text); + self.wrap_cache.replace(None); + if pos <= self.cursor_pos { + self.cursor_pos += text.len(); + } + self.shift_elements(pos, 0, text.len()); + self.preferred_col = None; + } + + pub fn replace_range(&mut self, range: std::ops::Range, text: &str) { + let range = self.expand_range_to_element_boundaries(range); + self.replace_range_raw(range, text); + } + + fn replace_range_raw(&mut self, range: std::ops::Range, text: &str) { + assert!(range.start <= range.end); + let start = range.start.clamp(0, self.text.len()); + let end = range.end.clamp(0, self.text.len()); + let removed_len = end - start; + let inserted_len = text.len(); + if removed_len == 0 && inserted_len == 0 { + return; + } + let diff = inserted_len as isize - removed_len as isize; + + self.text.replace_range(range, text); + self.wrap_cache.replace(None); + self.preferred_col = None; + self.update_elements_after_replace(start, end, inserted_len); + + // Update the cursor position to account for the edit. + self.cursor_pos = if self.cursor_pos < start { + // Cursor was before the edited range – no shift. + self.cursor_pos + } else if self.cursor_pos <= end { + // Cursor was inside the replaced range – move to end of the new text. + start + inserted_len + } else { + // Cursor was after the replaced range – shift by the length diff. + ((self.cursor_pos as isize) + diff) as usize + } + .min(self.text.len()); + + // Ensure cursor is not inside an element + self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + } + + pub fn cursor(&self) -> usize { + self.cursor_pos + } + + pub fn set_cursor(&mut self, pos: usize) { + self.cursor_pos = pos.clamp(0, self.text.len()); + self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + self.preferred_col = None; + } + + pub fn desired_height(&self, width: u16) -> u16 { + self.wrapped_lines(width).len() as u16 + } + + #[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()) + } + + /// Compute the on-screen cursor position taking scrolling into account. + 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)?; + let ls = &lines[i]; + let col = self.text[ls.start..self.cursor_pos].width() as u16; + let screen_row = i + .saturating_sub(effective_scroll as usize) + .try_into() + .unwrap_or(0); + Some((area.x + col, area.y + screen_row)) + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + fn current_display_col(&self) -> usize { + let bol = self.beginning_of_current_line(); + self.text[bol..self.cursor_pos].width() + } + + fn wrapped_line_index_by_start(lines: &[Range], pos: usize) -> Option { + // partition_point returns the index of the first element for which + // the predicate is false, i.e. the count of elements with start <= pos. + let idx = lines.partition_point(|r| r.start <= pos); + if idx == 0 { None } else { Some(idx - 1) } + } + + fn move_to_display_col_on_line( + &mut self, + line_start: usize, + line_end: usize, + target_col: usize, + ) { + let mut width_so_far = 0usize; + for (i, g) in self.text[line_start..line_end].grapheme_indices(true) { + width_so_far += g.width(); + if width_so_far > target_col { + self.cursor_pos = line_start + i; + // Avoid landing inside an element; round to nearest boundary + self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + return; + } + } + self.cursor_pos = line_end; + self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + } + + fn beginning_of_line(&self, pos: usize) -> usize { + self.text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0) + } + fn beginning_of_current_line(&self) -> usize { + self.beginning_of_line(self.cursor_pos) + } + + fn end_of_line(&self, pos: usize) -> usize { + self.text[pos..] + .find('\n') + .map(|i| i + pos) + .unwrap_or(self.text.len()) + } + fn end_of_current_line(&self) -> usize { + self.end_of_line(self.cursor_pos) + } + + pub fn input(&mut self, event: KeyEvent) { + match event { + // Some terminals (or configurations) send Control key chords as + // C0 control characters without reporting the CONTROL modifier. + // Handle common fallbacks for Ctrl-B/F/P/N here so they don't get + // inserted as literal control bytes. + KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => { + self.move_cursor_left(); + } + KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { + self.move_cursor_right(); + } + KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => { + self.move_cursor_up(); + } + KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => { + self.move_cursor_down(); + } + KeyEvent { + code: KeyCode::Char(c), + // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, + // because many terminals map Option/Meta combos to ALT+ (e.g. ESC f/ESC b) + // for word navigation. Those are handled explicitly below. + modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, + .. + } => self.insert_str(&c.to_string()), + KeyEvent { + code: KeyCode::Char('j' | 'm'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Enter, + .. + } => self.insert_str("\n"), + KeyEvent { + code: KeyCode::Char('h'), + modifiers, + .. + } if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { + self.delete_backward_word() + }, + // Windows AltGr generates ALT|CONTROL; treat as a plain character input unless + // we match a specific Control+Alt binding above. + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if is_altgr(modifiers) => self.insert_str(&c.to_string()), + KeyEvent { + code: KeyCode::Backspace, + modifiers: KeyModifiers::ALT, + .. + } => self.delete_backward_word(), + KeyEvent { + code: KeyCode::Backspace, + .. + } + | KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.delete_backward(1), + KeyEvent { + code: KeyCode::Delete, + modifiers: KeyModifiers::ALT, + .. + } => self.delete_forward_word(), + KeyEvent { + code: KeyCode::Delete, + .. + } + | KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.delete_forward(1), + + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.delete_backward_word(); + } + // Meta-b -> move to beginning of previous word + // Meta-f -> move to end of next word + // Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT). + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::ALT, + .. + } => { + self.set_cursor(self.beginning_of_previous_word()); + } + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::ALT, + .. + } => { + self.set_cursor(self.end_of_next_word()); + } + KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.kill_to_beginning_of_line(); + } + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.kill_to_end_of_line(); + } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.yank(); + } + + // Cursor movement + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_cursor_left(); + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_cursor_right(); + } + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_left(); + } + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_right(); + } + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_up(); + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_down(); + } + // Some terminals send Alt+Arrow for word-wise movement: + // Option/Left -> Alt+Left (previous word start) + // Option/Right -> Alt+Right (next word end) + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::ALT, + .. + } + | KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.set_cursor(self.beginning_of_previous_word()); + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::ALT, + .. + } + | KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.set_cursor(self.end_of_next_word()); + } + KeyEvent { + code: KeyCode::Up, .. + } => { + self.move_cursor_up(); + } + KeyEvent { + code: KeyCode::Down, + .. + } => { + self.move_cursor_down(); + } + KeyEvent { + code: KeyCode::Home, + .. + } => { + self.move_cursor_to_beginning_of_line(false); + } + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_to_beginning_of_line(true); + } + + KeyEvent { + code: KeyCode::End, .. + } => { + self.move_cursor_to_end_of_line(false); + } + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_to_end_of_line(true); + } + _o => { + #[cfg(feature = "debug-logs")] + tracing::debug!("Unhandled key event in TextArea: {:?}", _o); + } + } + } + + // ####### Input Functions ####### + pub fn delete_backward(&mut self, n: usize) { + if n == 0 || self.cursor_pos == 0 { + return; + } + let mut target = self.cursor_pos; + for _ in 0..n { + target = self.prev_atomic_boundary(target); + if target == 0 { + break; + } + } + self.replace_range(target..self.cursor_pos, ""); + } + + pub fn delete_forward(&mut self, n: usize) { + if n == 0 || self.cursor_pos >= self.text.len() { + return; + } + let mut target = self.cursor_pos; + for _ in 0..n { + target = self.next_atomic_boundary(target); + if target >= self.text.len() { + break; + } + } + self.replace_range(self.cursor_pos..target, ""); + } + + pub fn delete_backward_word(&mut self) { + let start = self.beginning_of_previous_word(); + self.kill_range(start..self.cursor_pos); + } + + /// Delete text to the right of the cursor using "word" semantics. + /// + /// Deletes from the current cursor position through the end of the next word as determined + /// by `end_of_next_word()`. Any whitespace (including newlines) between the cursor and that + /// word is included in the deletion. + pub fn delete_forward_word(&mut self) { + let end = self.end_of_next_word(); + if end > self.cursor_pos { + self.kill_range(self.cursor_pos..end); + } + } + + pub fn kill_to_end_of_line(&mut self) { + let eol = self.end_of_current_line(); + let range = if self.cursor_pos == eol { + if eol < self.text.len() { + Some(self.cursor_pos..eol + 1) + } else { + None + } + } else { + Some(self.cursor_pos..eol) + }; + + if let Some(range) = range { + self.kill_range(range); + } + } + + pub fn kill_to_beginning_of_line(&mut self) { + let bol = self.beginning_of_current_line(); + let range = if self.cursor_pos == bol { + if bol > 0 { Some(bol - 1..bol) } else { None } + } else { + Some(bol..self.cursor_pos) + }; + + if let Some(range) = range { + self.kill_range(range); + } + } + + pub fn yank(&mut self) { + if self.kill_buffer.is_empty() { + return; + } + let text = self.kill_buffer.clone(); + self.insert_str(&text); + } + + fn kill_range(&mut self, range: Range) { + let range = self.expand_range_to_element_boundaries(range); + if range.start >= range.end { + return; + } + + let removed = self.text[range.clone()].to_string(); + if removed.is_empty() { + return; + } + + self.kill_buffer = removed; + self.replace_range_raw(range, ""); + } + + /// Move the cursor left by a single grapheme cluster. + pub fn move_cursor_left(&mut self) { + self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); + self.preferred_col = None; + } + + /// Move the cursor right by a single grapheme cluster. + pub fn move_cursor_right(&mut self) { + self.cursor_pos = self.next_atomic_boundary(self.cursor_pos); + self.preferred_col = None; + } + + pub fn move_cursor_up(&mut self) { + // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. + if let Some((target_col, maybe_line)) = { + let cache_ref = self.wrap_cache.borrow(); + if let Some(cache) = cache_ref.as_ref() { + let lines = &cache.lines; + if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { + let cur_range = &lines[idx]; + let target_col = self + .preferred_col + .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); + if idx > 0 { + let prev = &lines[idx - 1]; + let line_start = prev.start; + let line_end = prev.end.saturating_sub(1); + Some((target_col, Some((line_start, line_end)))) + } else { + Some((target_col, None)) + } + } else { + None + } + } else { + None + } + } { + // We had wrapping info. Apply movement accordingly. + match maybe_line { + Some((line_start, line_end)) => { + if self.preferred_col.is_none() { + self.preferred_col = Some(target_col); + } + self.move_to_display_col_on_line(line_start, line_end, target_col); + return; + } + None => { + // Already at first visual line -> move to start + self.cursor_pos = 0; + self.preferred_col = None; + return; + } + } + } + + // Fallback to logical line navigation if we don't have wrapping info yet. + if let Some(prev_nl) = self.text[..self.cursor_pos].rfind('\n') { + let target_col = match self.preferred_col { + Some(c) => c, + None => { + let c = self.current_display_col(); + self.preferred_col = Some(c); + c + } + }; + let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0); + let prev_line_end = prev_nl; + self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col); + } else { + self.cursor_pos = 0; + self.preferred_col = None; + } + } + + pub fn move_cursor_down(&mut self) { + // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. + if let Some((target_col, move_to_last)) = { + let cache_ref = self.wrap_cache.borrow(); + if let Some(cache) = cache_ref.as_ref() { + let lines = &cache.lines; + if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { + let cur_range = &lines[idx]; + let target_col = self + .preferred_col + .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); + if idx + 1 < lines.len() { + let next = &lines[idx + 1]; + let line_start = next.start; + let line_end = next.end.saturating_sub(1); + Some((target_col, Some((line_start, line_end)))) + } else { + Some((target_col, None)) + } + } else { + None + } + } else { + None + } + } { + match move_to_last { + Some((line_start, line_end)) => { + if self.preferred_col.is_none() { + self.preferred_col = Some(target_col); + } + self.move_to_display_col_on_line(line_start, line_end, target_col); + return; + } + None => { + // Already on last visual line -> move to end + self.cursor_pos = self.text.len(); + self.preferred_col = None; + return; + } + } + } + + // Fallback to logical line navigation if we don't have wrapping info yet. + let target_col = match self.preferred_col { + Some(c) => c, + None => { + let c = self.current_display_col(); + self.preferred_col = Some(c); + c + } + }; + if let Some(next_nl) = self.text[self.cursor_pos..] + .find('\n') + .map(|i| i + self.cursor_pos) + { + let next_line_start = next_nl + 1; + let next_line_end = self.text[next_line_start..] + .find('\n') + .map(|i| i + next_line_start) + .unwrap_or(self.text.len()); + self.move_to_display_col_on_line(next_line_start, next_line_end, target_col); + } else { + self.cursor_pos = self.text.len(); + self.preferred_col = None; + } + } + + pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) { + let bol = self.beginning_of_current_line(); + if move_up_at_bol && self.cursor_pos == bol { + self.set_cursor(self.beginning_of_line(self.cursor_pos.saturating_sub(1))); + } else { + self.set_cursor(bol); + } + self.preferred_col = None; + } + + pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) { + let eol = self.end_of_current_line(); + if move_down_at_eol && self.cursor_pos == eol { + let next_pos = (self.cursor_pos.saturating_add(1)).min(self.text.len()); + self.set_cursor(self.end_of_line(next_pos)); + } else { + self.set_cursor(eol); + } + } + + // ===== Text elements support ===== + + pub fn insert_element(&mut self, text: &str) { + let start = self.clamp_pos_for_insertion(self.cursor_pos); + self.insert_str_at(start, text); + let end = start + text.len(); + self.add_element(start..end); + // Place cursor at end of inserted element + self.set_cursor(end); + } + + fn add_element(&mut self, range: Range) { + let elem = TextElement { range }; + self.elements.push(elem); + self.elements.sort_by_key(|e| e.range.start); + } + + fn find_element_containing(&self, pos: usize) -> Option { + self.elements + .iter() + .position(|e| pos > e.range.start && pos < e.range.end) + } + + fn clamp_pos_to_nearest_boundary(&self, mut pos: usize) -> usize { + if pos > self.text.len() { + pos = self.text.len(); + } + if let Some(idx) = self.find_element_containing(pos) { + let e = &self.elements[idx]; + let dist_start = pos.saturating_sub(e.range.start); + let dist_end = e.range.end.saturating_sub(pos); + if dist_start <= dist_end { + e.range.start + } else { + e.range.end + } + } else { + pos + } + } + + fn clamp_pos_for_insertion(&self, pos: usize) -> usize { + // Do not allow inserting into the middle of an element + if let Some(idx) = self.find_element_containing(pos) { + let e = &self.elements[idx]; + // Choose closest edge for insertion + let dist_start = pos.saturating_sub(e.range.start); + let dist_end = e.range.end.saturating_sub(pos); + if dist_start <= dist_end { + e.range.start + } else { + e.range.end + } + } else { + pos + } + } + + fn expand_range_to_element_boundaries(&self, mut range: Range) -> Range { + // Expand to include any intersecting elements fully + loop { + let mut changed = false; + for e in &self.elements { + if e.range.start < range.end && e.range.end > range.start { + let new_start = range.start.min(e.range.start); + let new_end = range.end.max(e.range.end); + if new_start != range.start || new_end != range.end { + range.start = new_start; + range.end = new_end; + changed = true; + } + } + } + if !changed { + break; + } + } + range + } + + fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) { + // Generic shift: for pure insert, removed = 0; for delete, inserted = 0. + let end = at + removed; + let diff = inserted as isize - removed as isize; + // Remove elements fully deleted by the operation and shift the rest + self.elements + .retain(|e| !(e.range.start >= at && e.range.end <= end)); + for e in &mut self.elements { + if e.range.end <= at { + // before edit + } else if e.range.start >= end { + // after edit + e.range.start = ((e.range.start as isize) + diff) as usize; + e.range.end = ((e.range.end as isize) + diff) as usize; + } else { + // Overlap with element but not fully contained (shouldn't happen when using + // element-aware replace, but degrade gracefully by snapping element to new bounds) + let new_start = at.min(e.range.start); + let new_end = at + inserted.max(e.range.end.saturating_sub(end)); + e.range.start = new_start; + e.range.end = new_end; + } + } + } + + fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) { + self.shift_elements(start, end.saturating_sub(start), inserted_len); + } + + fn prev_atomic_boundary(&self, pos: usize) -> usize { + if pos == 0 { + return 0; + } + // If currently at an element end or inside, jump to start of that element. + if let Some(idx) = self + .elements + .iter() + .position(|e| pos > e.range.start && pos <= e.range.end) + { + return self.elements[idx].range.start; + } + let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); + match gc.prev_boundary(&self.text, 0) { + Ok(Some(b)) => { + if let Some(idx) = self.find_element_containing(b) { + self.elements[idx].range.start + } else { + b + } + } + Ok(None) => 0, + Err(_) => pos.saturating_sub(1), + } + } + + fn next_atomic_boundary(&self, pos: usize) -> usize { + if pos >= self.text.len() { + return self.text.len(); + } + // If currently at an element start or inside, jump to end of that element. + if let Some(idx) = self + .elements + .iter() + .position(|e| pos >= e.range.start && pos < e.range.end) + { + return self.elements[idx].range.end; + } + let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); + match gc.next_boundary(&self.text, 0) { + Ok(Some(b)) => { + if let Some(idx) = self.find_element_containing(b) { + self.elements[idx].range.end + } else { + b + } + } + Ok(None) => self.text.len(), + Err(_) => pos.saturating_add(1), + } + } + + pub(crate) fn beginning_of_previous_word(&self) -> usize { + let prefix = &self.text[..self.cursor_pos]; + let Some((first_non_ws_idx, ch)) = prefix + .char_indices() + .rev() + .find(|&(_, ch)| !ch.is_whitespace()) + else { + return 0; + }; + let is_separator = is_word_separator(ch); + let mut start = first_non_ws_idx; + for (idx, ch) in prefix[..first_non_ws_idx].char_indices().rev() { + if ch.is_whitespace() || is_word_separator(ch) != is_separator { + start = idx + ch.len_utf8(); + break; + } + start = idx; + } + self.adjust_pos_out_of_elements(start, true) + } + + pub(crate) fn end_of_next_word(&self) -> usize { + let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace()) + else { + return self.text.len(); + }; + let word_start = self.cursor_pos + first_non_ws; + let mut iter = self.text[word_start..].char_indices(); + let Some((_, first_ch)) = iter.next() else { + return word_start; + }; + let is_separator = is_word_separator(first_ch); + let mut end = self.text.len(); + for (idx, ch) in iter { + if ch.is_whitespace() || is_word_separator(ch) != is_separator { + end = word_start + idx; + break; + } + } + self.adjust_pos_out_of_elements(end, false) + } + + fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { + if let Some(idx) = self.find_element_containing(pos) { + let e = &self.elements[idx]; + if prefer_start { + e.range.start + } else { + e.range.end + } + } else { + pos + } + } + + #[expect(clippy::unwrap_used)] + fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec>> { + // Ensure cache is ready (potentially mutably borrow, then drop) + { + let mut cache = self.wrap_cache.borrow_mut(); + let needs_recalc = match cache.as_ref() { + Some(c) => c.width != width, + None => true, + }; + if needs_recalc { + let lines = crate::wrapping::wrap_ranges( + &self.text, + Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ); + *cache = Some(WrapCache { width, lines }); + } + } + + let cache = self.wrap_cache.borrow(); + Ref::map(cache, |c| &c.as_ref().unwrap().lines) + } + + /// Calculate the scroll offset that should be used to satisfy the + /// invariants given the current area size and wrapped lines. + /// + /// - Cursor is always on screen. + /// - No scrolling if content fits in the area. + fn effective_scroll( + &self, + area_height: u16, + lines: &[Range], + current_scroll: u16, + ) -> u16 { + let total_lines = lines.len() as u16; + if area_height >= total_lines { + return 0; + } + + // Where is the cursor within wrapped lines? Prefer assigning boundary positions + // (where pos equals the start of a wrapped line) to that later line. + let cursor_line_idx = + Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16; + + let max_scroll = total_lines.saturating_sub(area_height); + let mut scroll = current_scroll.min(max_scroll); + + // Ensure cursor is visible within [scroll, scroll + area_height) + if cursor_line_idx < scroll { + scroll = cursor_line_idx; + } else if cursor_line_idx >= scroll + area_height { + scroll = cursor_line_idx + 1 - area_height; + } + scroll + } +} + +impl WidgetRef for &TextArea { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let lines = self.wrapped_lines(area.width); + self.render_lines(area, buf, &lines, 0..lines.len()); + } +} + +impl StatefulWidgetRef for &TextArea { + type State = TextAreaState; + + fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let lines = self.wrapped_lines(area.width); + let scroll = self.effective_scroll(area.height, &lines, state.scroll); + state.scroll = scroll; + + let start = scroll as usize; + let end = (scroll + area.height).min(lines.len() as u16) as usize; + self.render_lines(area, buf, &lines, start..end); + } +} + +impl TextArea { + fn render_lines( + &self, + area: Rect, + buf: &mut Buffer, + lines: &[Range], + range: std::ops::Range, + ) { + for (row, idx) in range.enumerate() { + let r = &lines[idx]; + let y = area.y + row as u16; + let line_range = r.start..r.end - 1; + // Draw base line with default style. + buf.set_string(area.x, y, &self.text[line_range.clone()], Style::default()); + + // Overlay styled segments for elements that intersect this line. + for elem in &self.elements { + // Compute overlap with displayed slice. + let overlap_start = elem.range.start.max(line_range.start); + let overlap_end = elem.range.end.min(line_range.end); + if overlap_start >= overlap_end { + continue; + } + let styled = &self.text[overlap_start..overlap_end]; + let x_off = self.text[line_range.start..overlap_start].width() as u16; + let style = Style::default().fg(Color::Cyan); + buf.set_string(area.x + x_off, y, styled, style); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + // crossterm types are intentionally not imported here to avoid unused warnings + use rand::prelude::*; + + fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String { + let r: u8 = rng.random_range(0..100); + match r { + 0..=4 => "\n".to_string(), + 5..=12 => " ".to_string(), + 13..=35 => (rng.random_range(b'a'..=b'z') as char).to_string(), + 36..=45 => (rng.random_range(b'A'..=b'Z') as char).to_string(), + 46..=52 => (rng.random_range(b'0'..=b'9') as char).to_string(), + 53..=65 => { + // Some emoji (wide graphemes) + let choices = ["👍", "😊", "🐍", "🚀", "🧪", "🌟"]; + choices[rng.random_range(0..choices.len())].to_string() + } + 66..=75 => { + // CJK wide characters + let choices = ["漢", "字", "測", "試", "你", "好", "界", "编", "码"]; + choices[rng.random_range(0..choices.len())].to_string() + } + 76..=85 => { + // Combining mark sequences + let base = ["e", "a", "o", "n", "u"][rng.random_range(0..5)]; + let marks = ["\u{0301}", "\u{0308}", "\u{0302}", "\u{0303}"]; + format!("{base}{}", marks[rng.random_range(0..marks.len())]) + } + 86..=92 => { + // Some non-latin single codepoints (Greek, Cyrillic, Hebrew) + let choices = ["Ω", "β", "Ж", "ю", "ש", "م", "ह"]; + choices[rng.random_range(0..choices.len())].to_string() + } + _ => { + // ZWJ sequences (single graphemes but multi-codepoint) + let choices = [ + "👩\u{200D}💻", // woman technologist + "👨\u{200D}💻", // man technologist + "🏳️\u{200D}🌈", // rainbow flag + ]; + choices[rng.random_range(0..choices.len())].to_string() + } + } + } + + fn ta_with(text: &str) -> TextArea { + let mut t = TextArea::new(); + t.insert_str(text); + t + } + + #[test] + fn insert_and_replace_update_cursor_and_text() { + // insert helpers + let mut t = ta_with("hello"); + t.set_cursor(5); + t.insert_str("!"); + assert_eq!(t.text(), "hello!"); + assert_eq!(t.cursor(), 6); + + t.insert_str_at(0, "X"); + assert_eq!(t.text(), "Xhello!"); + assert_eq!(t.cursor(), 7); + + // Insert after the cursor should not move it + t.set_cursor(1); + let end = t.text().len(); + t.insert_str_at(end, "Y"); + assert_eq!(t.text(), "Xhello!Y"); + assert_eq!(t.cursor(), 1); + + // replace_range cases + // 1) cursor before range + let mut t = ta_with("abcd"); + t.set_cursor(1); + t.replace_range(2..3, "Z"); + assert_eq!(t.text(), "abZd"); + assert_eq!(t.cursor(), 1); + + // 2) cursor inside range + let mut t = ta_with("abcd"); + t.set_cursor(2); + t.replace_range(1..3, "Q"); + assert_eq!(t.text(), "aQd"); + assert_eq!(t.cursor(), 2); + + // 3) cursor after range with shifted by diff + let mut t = ta_with("abcd"); + t.set_cursor(4); + t.replace_range(0..1, "AA"); + assert_eq!(t.text(), "AAbcd"); + assert_eq!(t.cursor(), 5); + } + + #[test] + fn delete_backward_and_forward_edges() { + let mut t = ta_with("abc"); + t.set_cursor(1); + t.delete_backward(1); + assert_eq!(t.text(), "bc"); + assert_eq!(t.cursor(), 0); + + // deleting backward at start is a no-op + t.set_cursor(0); + t.delete_backward(1); + assert_eq!(t.text(), "bc"); + assert_eq!(t.cursor(), 0); + + // forward delete removes next grapheme + t.set_cursor(1); + t.delete_forward(1); + assert_eq!(t.text(), "b"); + assert_eq!(t.cursor(), 1); + + // forward delete at end is a no-op + t.set_cursor(t.text().len()); + t.delete_forward(1); + assert_eq!(t.text(), "b"); + } + + #[test] + fn delete_backward_word_and_kill_line_variants() { + // delete backward word at end removes the whole previous word + let mut t = ta_with("hello world "); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 8); + + // From inside a word, delete from word start to cursor + let mut t = ta_with("foo bar"); + t.set_cursor(6); // inside "bar" (after 'a') + t.delete_backward_word(); + assert_eq!(t.text(), "foo r"); + assert_eq!(t.cursor(), 4); + + // From end, delete the last word only + let mut t = ta_with("foo bar"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo "); + assert_eq!(t.cursor(), 4); + + // kill_to_end_of_line when not at EOL + let mut t = ta_with("abc\ndef"); + t.set_cursor(1); // on first line, middle + t.kill_to_end_of_line(); + assert_eq!(t.text(), "a\ndef"); + assert_eq!(t.cursor(), 1); + + // kill_to_end_of_line when at EOL deletes newline + let mut t = ta_with("abc\ndef"); + t.set_cursor(3); // EOL of first line + t.kill_to_end_of_line(); + assert_eq!(t.text(), "abcdef"); + assert_eq!(t.cursor(), 3); + + // kill_to_beginning_of_line from middle of line + let mut t = ta_with("abc\ndef"); + t.set_cursor(5); // on second line, after 'e' + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), "abc\nef"); + + // kill_to_beginning_of_line at beginning of non-first line removes the previous newline + let mut t = ta_with("abc\ndef"); + t.set_cursor(4); // beginning of second line + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), "abcdef"); + assert_eq!(t.cursor(), 3); + } + + #[test] + fn delete_forward_word_variants() { + let mut t = ta_with("hello world "); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " world "); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with("hello world "); + t.set_cursor(1); + t.delete_forward_word(); + assert_eq!(t.text(), "h world "); + assert_eq!(t.cursor(), 1); + + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); + t.delete_forward_word(); + assert_eq!(t.text(), "hello world"); + assert_eq!(t.cursor(), t.text().len()); + + let mut t = ta_with("foo \nbar"); + t.set_cursor(3); + t.delete_forward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 3); + + let mut t = ta_with("foo\nbar"); + t.set_cursor(3); + t.delete_forward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 3); + + let mut t = ta_with("hello world "); + t.set_cursor(t.text().len() + 10); + t.delete_forward_word(); + assert_eq!(t.text(), "hello world "); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn delete_forward_word_handles_atomic_elements() { + let mut t = TextArea::new(); + t.insert_element(""); + t.insert_str(" tail"); + + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " tail"); + assert_eq!(t.cursor(), 0); + + let mut t = TextArea::new(); + t.insert_str(" "); + t.insert_element(""); + t.insert_str(" tail"); + + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " tail"); + assert_eq!(t.cursor(), 0); + + let mut t = TextArea::new(); + t.insert_str("prefix "); + t.insert_element(""); + t.insert_str(" tail"); + + // cursor in the middle of the element, delete_forward_word deletes the element + let elem_range = t.elements[0].range.clone(); + t.cursor_pos = elem_range.start + (elem_range.len() / 2); + t.delete_forward_word(); + assert_eq!(t.text(), "prefix tail"); + assert_eq!(t.cursor(), elem_range.start); + } + + #[test] + fn delete_backward_word_respects_word_separators() { + let mut t = ta_with("path/to/file"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "path/to/"); + assert_eq!(t.cursor(), t.text().len()); + + t.delete_backward_word(); + assert_eq!(t.text(), "path/to"); + assert_eq!(t.cursor(), t.text().len()); + + let mut t = ta_with("foo/ "); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 3); + + let mut t = ta_with("foo /"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo "); + assert_eq!(t.cursor(), 4); + } + + #[test] + fn delete_forward_word_respects_word_separators() { + let mut t = ta_with("path/to/file"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), "/to/file"); + assert_eq!(t.cursor(), 0); + + t.delete_forward_word(); + assert_eq!(t.text(), "to/file"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with("/ foo"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " foo"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with(" /foo"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 0); + } + + #[test] + fn yank_restores_last_kill() { + let mut t = ta_with("hello"); + t.set_cursor(0); + t.kill_to_end_of_line(); + assert_eq!(t.text(), ""); + assert_eq!(t.cursor(), 0); + + t.yank(); + assert_eq!(t.text(), "hello"); + assert_eq!(t.cursor(), 5); + + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + + t.yank(); + assert_eq!(t.text(), "hello world"); + assert_eq!(t.cursor(), 11); + + let mut t = ta_with("hello"); + t.set_cursor(5); + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), ""); + assert_eq!(t.cursor(), 0); + + t.yank(); + assert_eq!(t.text(), "hello"); + assert_eq!(t.cursor(), 5); + } + + #[test] + fn cursor_left_and_right_handle_graphemes() { + let mut t = ta_with("a👍b"); + t.set_cursor(t.text().len()); + + t.move_cursor_left(); // before 'b' + let after_first_left = t.cursor(); + t.move_cursor_left(); // before '👍' + let after_second_left = t.cursor(); + t.move_cursor_left(); // before 'a' + let after_third_left = t.cursor(); + + assert!(after_first_left < t.text().len()); + assert!(after_second_left < after_first_left); + assert!(after_third_left < after_second_left); + + // Move right back to end safely + t.move_cursor_right(); + t.move_cursor_right(); + t.move_cursor_right(); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn control_b_and_f_move_cursor() { + let mut t = ta_with("abcd"); + t.set_cursor(1); + + t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); + assert_eq!(t.cursor(), 2); + + t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn control_b_f_fallback_control_chars_move_cursor() { + let mut t = ta_with("abcd"); + t.set_cursor(2); + + // Simulate terminals that send C0 control chars without CONTROL modifier. + // ^B (U+0002) should move left + t.input(KeyEvent::new(KeyCode::Char('\u{0002}'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 1); + + // ^F (U+0006) should move right + t.input(KeyEvent::new(KeyCode::Char('\u{0006}'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 2); + } + + #[test] + fn delete_backward_word_alt_keys() { + // Test the custom Alt+Ctrl+h binding + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); // cursor at the end + t.input(KeyEvent::new( + KeyCode::Char('h'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + + // Test the standard Alt+Backspace binding + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); // cursor at the end + t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + } + + #[test] + fn delete_backward_word_handles_narrow_no_break_space() { + let mut t = ta_with("32\u{202F}AM"); + t.set_cursor(t.text().len()); + t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); + pretty_assertions::assert_eq!(t.text(), "32\u{202F}"); + pretty_assertions::assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn delete_forward_word_with_without_alt_modifier() { + let mut t = ta_with("hello world"); + t.set_cursor(0); + t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT)); + assert_eq!(t.text(), " world"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with("hello"); + t.set_cursor(0); + t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + assert_eq!(t.text(), "ello"); + assert_eq!(t.cursor(), 0); + } + + #[test] + fn control_h_backspace() { + // Test Ctrl+H as backspace + let mut t = ta_with("12345"); + t.set_cursor(3); // cursor after '3' + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(t.text(), "1245"); + assert_eq!(t.cursor(), 2); + + // Test Ctrl+H at beginning (should be no-op) + t.set_cursor(0); + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(t.text(), "1245"); + assert_eq!(t.cursor(), 0); + + // Test Ctrl+H at end + t.set_cursor(t.text().len()); + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(t.text(), "124"); + assert_eq!(t.cursor(), 3); + } + + #[cfg_attr(not(windows), ignore = "AltGr modifier only applies on Windows")] + #[test] + fn altgr_ctrl_alt_char_inserts_literal() { + let mut t = ta_with(""); + t.input(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )); + assert_eq!(t.text(), "c"); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn cursor_vertical_movement_across_lines_and_bounds() { + let mut t = ta_with("short\nloooooooooong\nmid"); + // Place cursor on second line, column 5 + let second_line_start = 6; // after first '\n' + t.set_cursor(second_line_start + 5); + + // Move up: target column preserved, clamped by line length + t.move_cursor_up(); + assert_eq!(t.cursor(), 5); // first line has len 5 + + // Move up again goes to start of text + t.move_cursor_up(); + assert_eq!(t.cursor(), 0); + + // Move down: from start to target col tracked + t.move_cursor_down(); + // On first move down, we should land on second line, at col 0 (target col remembered as 0) + let pos_after_down = t.cursor(); + assert!(pos_after_down >= second_line_start); + + // Move down again to third line; clamp to its length + t.move_cursor_down(); + let third_line_start = t.text().find("mid").unwrap(); + let third_line_end = third_line_start + 3; + assert!(t.cursor() >= third_line_start && t.cursor() <= third_line_end); + + // Moving down at last line jumps to end + t.move_cursor_down(); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn home_end_and_emacs_style_home_end() { + let mut t = ta_with("one\ntwo\nthree"); + // Position at middle of second line + let second_line_start = t.text().find("two").unwrap(); + t.set_cursor(second_line_start + 1); + + t.move_cursor_to_beginning_of_line(false); + assert_eq!(t.cursor(), second_line_start); + + // Ctrl-A behavior: if at BOL, go to beginning of previous line + t.move_cursor_to_beginning_of_line(true); + assert_eq!(t.cursor(), 0); // beginning of first line + + // Move to EOL of first line + t.move_cursor_to_end_of_line(false); + assert_eq!(t.cursor(), 3); + + // Ctrl-E: if at EOL, go to end of next line + t.move_cursor_to_end_of_line(true); + // end of second line ("two") is right before its '\n' + let end_second_nl = t.text().find("\nthree").unwrap(); + assert_eq!(t.cursor(), end_second_nl); + } + + #[test] + fn end_of_line_or_down_at_end_of_text() { + let mut t = ta_with("one\ntwo"); + // Place cursor at absolute end of the text + t.set_cursor(t.text().len()); + // Should remain at end without panicking + t.move_cursor_to_end_of_line(true); + assert_eq!(t.cursor(), t.text().len()); + + // Also verify behavior when at EOL of a non-final line: + let eol_first_line = 3; // index of '\n' in "one\ntwo" + t.set_cursor(eol_first_line); + t.move_cursor_to_end_of_line(true); + assert_eq!(t.cursor(), t.text().len()); // moves to end of next (last) line + } + + #[test] + fn word_navigation_helpers() { + let t = ta_with(" alpha beta gamma"); + let mut t = t; // make mutable for set_cursor + // Put cursor after "alpha" + let after_alpha = t.text().find("alpha").unwrap() + "alpha".len(); + t.set_cursor(after_alpha); + assert_eq!(t.beginning_of_previous_word(), 2); // skip initial spaces + + // Put cursor at start of beta + let beta_start = t.text().find("beta").unwrap(); + t.set_cursor(beta_start); + assert_eq!(t.end_of_next_word(), beta_start + "beta".len()); + + // If at end, end_of_next_word returns len + t.set_cursor(t.text().len()); + assert_eq!(t.end_of_next_word(), t.text().len()); + } + + #[test] + fn wrapping_and_cursor_positions() { + let mut t = ta_with("hello world here"); + let area = Rect::new(0, 0, 6, 10); // width 6 -> wraps words + // desired height counts wrapped lines + assert!(t.desired_height(area.width) >= 3); + + // Place cursor in "world" + let world_start = t.text().find("world").unwrap(); + t.set_cursor(world_start + 3); + let (_x, y) = t.cursor_pos(area).unwrap(); + assert_eq!(y, 1); // world should be on second wrapped line + + // With state and small height, cursor is mapped onto visible row + 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(); + assert_eq!(y, 0); + + // Render with state to update actual scroll value + let mut buf = Buffer::empty(small_area); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), small_area, &mut buf, &mut state); + // After render, state.scroll should be adjusted so cursor row fits + let effective_lines = t.desired_height(small_area.width); + assert!(state.scroll < effective_lines); + } + + #[test] + fn cursor_pos_with_state_basic_and_scroll_behaviors() { + // Case 1: No wrapping needed, height fits — scroll ignored, y maps directly. + let mut t = ta_with("hello world"); + t.set_cursor(3); + let area = Rect::new(2, 5, 20, 3); + // Even if an absurd scroll is provided, when content fits the area the + // 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(); + assert_eq!((x2, y2), (x1, y1)); + + // Case 2: Cursor below the current window — y should be clamped to the + // bottom row (area.height - 1) after adjusting effective scroll. + let mut t = ta_with("one two three four five six"); + // Force wrapping to many visual lines. + let wrap_width = 4; + let _ = t.desired_height(wrap_width); + // Put cursor somewhere near the end so it's definitely below the first window. + 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(); + assert_eq!(y, small_area.y + small_area.height - 1); + + // Case 3: Cursor above the current window — y should be top row (0) + // when the provided scroll is too large. + let mut t = ta_with("alpha beta gamma delta epsilon zeta"); + let wrap_width = 5; + let lines = t.desired_height(wrap_width); + // Place cursor near start so an excessive scroll moves it to top row. + t.set_cursor(1); + let area = Rect::new(0, 0, wrap_width, 3); + let state = TextAreaState { + scroll: lines.saturating_mul(2), + }; + let (_x, y) = t.cursor_pos_with_state(area, state).unwrap(); + assert_eq!(y, area.y); + } + + #[test] + fn wrapped_navigation_across_visual_lines() { + let mut t = ta_with("abcdefghij"); + // Force wrapping at width 4: lines -> ["abcd", "efgh", "ij"] + let _ = t.desired_height(4); + + // From the very start, moving down should go to the start of the next wrapped line (index 4) + t.set_cursor(0); + t.move_cursor_down(); + assert_eq!(t.cursor(), 4); + + // Cursor at boundary index 4 should be displayed at start of second wrapped line + t.set_cursor(4); + let area = Rect::new(0, 0, 4, 10); + let (x, y) = t.cursor_pos(area).unwrap(); + assert_eq!((x, y), (0, 1)); + + // 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(); + assert_eq!((x, y), (0, 0)); + + // Place cursor in the middle of the second wrapped line ("efgh"), at 'g' + t.set_cursor(6); + // Move up should go to same column on previous wrapped line -> index 2 ('c') + t.move_cursor_up(); + assert_eq!(t.cursor(), 2); + + // Move down should return to same position on the next wrapped line -> back to index 6 ('g') + t.move_cursor_down(); + assert_eq!(t.cursor(), 6); + + // Move down again should go to third wrapped line. Target col is 2, but the line has len 2 -> clamp to end + t.move_cursor_down(); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn cursor_pos_with_state_after_movements() { + let mut t = ta_with("abcdefghij"); + // Wrap width 4 -> visual lines: abcd | efgh | ij + let _ = t.desired_height(4); + let area = Rect::new(0, 0, 4, 2); + let mut state = TextAreaState::default(); + let mut buf = Buffer::empty(area); + + // 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + assert_eq!((x1, y1), (2, 1)); + } + + #[test] + fn wrapped_navigation_with_newlines_and_spaces() { + // Include spaces and an explicit newline to exercise boundaries + let mut t = ta_with("word1 word2\nword3"); + // Width 6 will wrap "word1 " and then "word2" before the newline + let _ = t.desired_height(6); + + // Put cursor on the second wrapped line before the newline, at column 1 of "word2" + let start_word2 = t.text().find("word2").unwrap(); + t.set_cursor(start_word2 + 1); + + // Up should go to first wrapped line, column 1 -> index 1 + t.move_cursor_up(); + assert_eq!(t.cursor(), 1); + + // Down should return to the same visual column on "word2" + t.move_cursor_down(); + assert_eq!(t.cursor(), start_word2 + 1); + + // Down again should cross the logical newline to the next visual line ("word3"), clamped to its length if needed + t.move_cursor_down(); + let start_word3 = t.text().find("word3").unwrap(); + assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len()); + } + + #[test] + fn wrapped_navigation_with_wide_graphemes() { + // Four thumbs up, each of display width 2, with width 3 to force wrapping inside grapheme boundaries + let mut t = ta_with("👍👍👍👍"); + let _ = t.desired_height(3); + + // Put cursor after the second emoji (which should be on first wrapped line) + t.set_cursor("👍👍".len()); + + // Move down should go to the start of the next wrapped line (same column preserved but clamped) + t.move_cursor_down(); + // We expect to land somewhere within the third emoji or at the start of it + let pos_after_down = t.cursor(); + assert!(pos_after_down >= "👍👍".len()); + + // Moving up should take us back to the original position + t.move_cursor_up(); + assert_eq!(t.cursor(), "👍👍".len()); + } + + #[test] + fn fuzz_textarea_randomized() { + // Deterministic seed for reproducibility + // Seed the RNG based on the current day in Pacific Time (PST/PDT). This + // keeps the fuzz test deterministic within a day while still varying + // day-to-day to improve coverage. + let pst_today_seed: u64 = (chrono::Utc::now() - chrono::Duration::hours(8)) + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .timestamp() as u64; + let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed); + + for _case in 0..500 { + let mut ta = TextArea::new(); + let mut state = TextAreaState::default(); + // Track element payloads we insert. Payloads use characters '[' and ']' which + // are not produced by rand_grapheme(), avoiding accidental collisions. + let mut elem_texts: Vec = Vec::new(); + let mut next_elem_id: usize = 0; + // Start with a random base string + let base_len = rng.random_range(0..30); + let mut base = String::new(); + for _ in 0..base_len { + base.push_str(&rand_grapheme(&mut rng)); + } + ta.set_text(&base); + // Choose a valid char boundary for initial cursor + let mut boundaries: Vec = vec![0]; + boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); + boundaries.push(ta.text().len()); + let init = boundaries[rng.random_range(0..boundaries.len())]; + ta.set_cursor(init); + + let mut width: u16 = rng.random_range(1..=12); + let mut height: u16 = rng.random_range(1..=4); + + for _step in 0..60 { + // Mostly stable width/height, occasionally change + if rng.random_bool(0.1) { + width = rng.random_range(1..=12); + } + if rng.random_bool(0.1) { + height = rng.random_range(1..=4); + } + + // Pick an operation + match rng.random_range(0..18) { + 0 => { + // insert small random string at cursor + let len = rng.random_range(0..6); + let mut s = String::new(); + for _ in 0..len { + s.push_str(&rand_grapheme(&mut rng)); + } + ta.insert_str(&s); + } + 1 => { + // replace_range with small random slice + let mut b: Vec = vec![0]; + b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); + b.push(ta.text().len()); + let i1 = rng.random_range(0..b.len()); + let i2 = rng.random_range(0..b.len()); + let (start, end) = if b[i1] <= b[i2] { + (b[i1], b[i2]) + } else { + (b[i2], b[i1]) + }; + let insert_len = rng.random_range(0..=4); + let mut s = String::new(); + for _ in 0..insert_len { + s.push_str(&rand_grapheme(&mut rng)); + } + let before = ta.text().len(); + // If the chosen range intersects an element, replace_range will expand to + // element boundaries, so the naive size delta assertion does not hold. + let intersects_element = elem_texts.iter().any(|payload| { + if let Some(pstart) = ta.text().find(payload) { + let pend = pstart + payload.len(); + pstart < end && pend > start + } else { + false + } + }); + ta.replace_range(start..end, &s); + if !intersects_element { + let after = ta.text().len(); + assert_eq!( + after as isize, + before as isize + (s.len() as isize) - ((end - start) as isize) + ); + } + } + 2 => ta.delete_backward(rng.random_range(0..=3)), + 3 => ta.delete_forward(rng.random_range(0..=3)), + 4 => ta.delete_backward_word(), + 5 => ta.kill_to_beginning_of_line(), + 6 => ta.kill_to_end_of_line(), + 7 => ta.move_cursor_left(), + 8 => ta.move_cursor_right(), + 9 => ta.move_cursor_up(), + 10 => ta.move_cursor_down(), + 11 => ta.move_cursor_to_beginning_of_line(true), + 12 => ta.move_cursor_to_end_of_line(true), + 13 => { + // Insert an element with a unique sentinel payload + let payload = + format!("[[EL#{}:{}]]", next_elem_id, rng.random_range(1000..9999)); + next_elem_id += 1; + ta.insert_element(&payload); + elem_texts.push(payload); + } + 14 => { + // Try inserting inside an existing element (should clamp to boundary) + if let Some(payload) = elem_texts.choose(&mut rng).cloned() + && let Some(start) = ta.text().find(&payload) + { + let end = start + payload.len(); + if end - start > 2 { + let pos = rng.random_range(start + 1..end - 1); + let ins = rand_grapheme(&mut rng); + ta.insert_str_at(pos, &ins); + } + } + } + 15 => { + // Replace a range that intersects an element -> whole element should be replaced + if let Some(payload) = elem_texts.choose(&mut rng).cloned() + && let Some(start) = ta.text().find(&payload) + { + let end = start + payload.len(); + // Create an intersecting range [start-δ, end-δ2) + let mut s = start.saturating_sub(rng.random_range(0..=2)); + let mut e = (end + rng.random_range(0..=2)).min(ta.text().len()); + // Align to char boundaries to satisfy String::replace_range contract + let txt = ta.text(); + while s > 0 && !txt.is_char_boundary(s) { + s -= 1; + } + while e < txt.len() && !txt.is_char_boundary(e) { + e += 1; + } + if s < e { + // Small replacement text + let mut srep = String::new(); + for _ in 0..rng.random_range(0..=2) { + srep.push_str(&rand_grapheme(&mut rng)); + } + ta.replace_range(s..e, &srep); + } + } + } + 16 => { + // Try setting the cursor to a position inside an element; it should clamp out + if let Some(payload) = elem_texts.choose(&mut rng).cloned() + && let Some(start) = ta.text().find(&payload) + { + let end = start + payload.len(); + if end - start > 2 { + let pos = rng.random_range(start + 1..end - 1); + ta.set_cursor(pos); + } + } + } + _ => { + // Jump to word boundaries + if rng.random_bool(0.5) { + let p = ta.beginning_of_previous_word(); + ta.set_cursor(p); + } else { + let p = ta.end_of_next_word(); + ta.set_cursor(p); + } + } + } + + // Sanity invariants + assert!(ta.cursor() <= ta.text().len()); + + // Element invariants + for payload in &elem_texts { + if let Some(start) = ta.text().find(payload) { + let end = start + payload.len(); + // 1) Text inside elements matches the initially set payload + assert_eq!(&ta.text()[start..end], payload); + // 2) Cursor is never strictly inside an element + let c = ta.cursor(); + assert!( + c <= start || c >= end, + "cursor inside element: {start}..{end} at {c}" + ); + } + } + + // Render and compute cursor positions; ensure they are in-bounds and do not panic + let area = Rect::new(0, 0, width, height); + // Stateless render into an area tall enough for all wrapped lines + let total_lines = ta.desired_height(width); + let full_area = Rect::new(0, 0, width, total_lines.max(1)); + let mut buf = Buffer::empty(full_area); + ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf); + + // cursor_pos: x must be within width when present + let _ = ta.cursor_pos(area); + + // cursor_pos_with_state: always within viewport rows + let (_x, _y) = ta + .cursor_pos_with_state(area, state) + .unwrap_or((area.x, area.y)); + + // Stateful render should not panic, and updates scroll + let mut sbuf = Buffer::empty(area); + ratatui::widgets::StatefulWidgetRef::render_ref( + &(&ta), + area, + &mut sbuf, + &mut state, + ); + + // After wrapping, desired height equals the number of lines we would render without scroll + let total_lines = total_lines as usize; + // state.scroll must not exceed total_lines when content fits within area height + if (height as usize) >= total_lines { + assert_eq!(state.scroll, 0); + } + } + } + } +} diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs new file mode 100644 index 00000000000..ea29c00d937 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget.rs @@ -0,0 +1,3463 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use codex_app_server_protocol::AuthMode; +use codex_backend_client::Client as BackendClient; +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::openai_models::model_family::ModelFamily; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; +use codex_core::protocol::AgentMessageDeltaEvent; +use codex_core::protocol::AgentMessageEvent; +use codex_core::protocol::AgentReasoningDeltaEvent; +use codex_core::protocol::AgentReasoningEvent; +use codex_core::protocol::AgentReasoningRawContentDeltaEvent; +use codex_core::protocol::AgentReasoningRawContentEvent; +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::CreditsSnapshot; +use codex_core::protocol::DeprecationNoticeEvent; +use codex_core::protocol::ErrorEvent; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::ExitedReviewModeEvent; +use codex_core::protocol::ListCustomPromptsResponseEvent; +use codex_core::protocol::McpListToolsResponseEvent; +use codex_core::protocol::McpStartupCompleteEvent; +use codex_core::protocol::McpStartupStatus; +use codex_core::protocol::McpStartupUpdateEvent; +use codex_core::protocol::McpToolCallBeginEvent; +use codex_core::protocol::McpToolCallEndEvent; +use codex_core::protocol::Op; +use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::RateLimitSnapshot; +use codex_core::protocol::ReviewRequest; +use codex_core::protocol::ReviewTarget; +use codex_core::protocol::StreamErrorEvent; +use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TerminalInteractionEvent; +use codex_core::protocol::TokenUsage; +use codex_core::protocol::TokenUsageInfo; +use codex_core::protocol::TurnAbortReason; +use codex_core::protocol::TurnDiffEvent; +use codex_core::protocol::UndoCompletedEvent; +use codex_core::protocol::UndoStartedEvent; +use codex_core::protocol::UserMessageEvent; +use codex_core::protocol::ViewImageToolCallEvent; +use codex_core::protocol::WarningEvent; +use codex_core::protocol::WebSearchBeginEvent; +use codex_core::protocol::WebSearchEndEvent; +use codex_core::skills::model::SkillMetadata; +use codex_protocol::ConversationId; +use codex_protocol::account::PlanType; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::user_input::UserInput; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use rand::Rng; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use tokio::sync::mpsc::UnboundedSender; +use tokio::task::JoinHandle; +use tracing::debug; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::BottomPane; +use crate::bottom_pane::BottomPaneParams; +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::exec_cell::CommandOutput; +use crate::exec_cell::ExecCell; +use crate::exec_cell::new_active_exec_command; +use crate::get_git_diff::get_git_diff; +use crate::history_cell; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::McpToolCallCell; +use crate::history_cell::PlainHistoryCell; +use crate::markdown::append_markdown; +use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::FlexRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt; +use crate::render::renderable::RenderableItem; +use crate::slash_command::SlashCommand; +use crate::status::RateLimitSnapshotDisplay; +use crate::text_formatting::truncate_text; +use crate::tui::FrameRequester; +mod interrupts; +use self::interrupts::InterruptManager; +mod agent; +use self::agent::spawn_agent; +use self::agent::spawn_agent_from_existing; +mod session_header; +use self::session_header::SessionHeader; +use crate::streaming::controller::StreamController; +use std::path::Path; + +use chrono::Local; +use codex_common::approval_presets::ApprovalPreset; +use codex_common::approval_presets::builtin_approval_presets; +use codex_core::AuthManager; +use codex_core::CodexAuth; +use codex_core::ConversationManager; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::SandboxPolicy; +use codex_file_search::FileMatch; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::plan_tool::UpdatePlanArgs; +use strum::IntoEnumIterator; + +const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; +const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; +// Track information about an in-flight exec command. +struct RunningCommand { + command: Vec, + parsed_cmd: Vec, + source: ExecCommandSource, +} + +struct UnifiedExecWaitState { + command_display: String, +} + +impl UnifiedExecWaitState { + fn new(command_display: String) -> Self { + Self { command_display } + } + + fn is_duplicate(&self, command_display: &str) -> bool { + self.command_display == command_display + } +} + +const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; +const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini"; +const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; + +#[derive(Default)] +struct RateLimitWarningState { + secondary_index: usize, + primary_index: usize, +} + +impl RateLimitWarningState { + fn take_warnings( + &mut self, + secondary_used_percent: Option, + secondary_window_minutes: Option, + primary_used_percent: Option, + primary_window_minutes: Option, + ) -> Vec { + let reached_secondary_cap = + matches!(secondary_used_percent, Some(percent) if percent == 100.0); + let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0); + if reached_secondary_cap || reached_primary_cap { + return Vec::new(); + } + + let mut warnings = Vec::new(); + + if let Some(secondary_used_percent) = secondary_used_percent { + let mut highest_secondary: Option = None; + while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] + { + highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); + self.secondary_index += 1; + } + if let Some(threshold) = highest_secondary { + let limit_label = secondary_window_minutes + .map(get_limits_duration) + .unwrap_or_else(|| "weekly".to_string()); + let remaining_percent = 100.0 - threshold; + warnings.push(format!( + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." + )); + } + } + + if let Some(primary_used_percent) = primary_used_percent { + let mut highest_primary: Option = None; + while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] + { + highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); + self.primary_index += 1; + } + if let Some(threshold) = highest_primary { + let limit_label = primary_window_minutes + .map(get_limits_duration) + .unwrap_or_else(|| "5h".to_string()); + let remaining_percent = 100.0 - threshold; + warnings.push(format!( + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." + )); + } + } + + warnings + } +} + +pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { + const MINUTES_PER_HOUR: i64 = 60; + const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; + const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; + const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; + const ROUNDING_BIAS_MINUTES: i64 = 3; + + let windows_minutes = windows_minutes.max(0); + + if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { + let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); + let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); + format!("{hours}h") + } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { + "weekly".to_string() + } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { + "monthly".to_string() + } else { + "annual".to_string() + } +} + +/// Common initialization parameters shared by all `ChatWidget` constructors. +pub(crate) struct ChatWidgetInit { + pub(crate) config: Config, + pub(crate) frame_requester: FrameRequester, + pub(crate) app_event_tx: AppEventSender, + pub(crate) initial_prompt: Option, + pub(crate) initial_images: Vec, + pub(crate) enhanced_keys_supported: bool, + pub(crate) auth_manager: Arc, + pub(crate) models_manager: Arc, + pub(crate) feedback: codex_feedback::CodexFeedback, + pub(crate) skills: Option>, + pub(crate) is_first_run: bool, + pub(crate) model_family: ModelFamily, +} + +#[derive(Default)] +enum RateLimitSwitchPromptState { + #[default] + Idle, + Pending, + Shown, +} + +pub(crate) struct ChatWidget { + app_event_tx: AppEventSender, + codex_op_tx: UnboundedSender, + bottom_pane: BottomPane, + active_cell: Option>, + config: Config, + model_family: ModelFamily, + auth_manager: Arc, + models_manager: Arc, + session_header: SessionHeader, + initial_user_message: Option, + token_info: Option, + rate_limit_snapshot: Option, + plan_type: Option, + rate_limit_warnings: RateLimitWarningState, + rate_limit_switch_prompt: RateLimitSwitchPromptState, + rate_limit_poller: Option>, + // Stream lifecycle controller + stream_controller: Option, + running_commands: HashMap, + suppressed_exec_calls: HashSet, + last_unified_wait: Option, + task_complete_pending: bool, + mcp_startup_status: Option>, + // Queue of interruptive UI events deferred during an active write cycle + interrupts: InterruptManager, + // Accumulates the current reasoning block text to extract a header + reasoning_buffer: String, + // Accumulates full reasoning content for transcript-only recording + full_reasoning_buffer: String, + // Current status header shown in the status indicator. + current_status_header: String, + // Previous status header to restore after a transient stream retry. + retry_status_header: Option, + conversation_id: Option, + frame_requester: FrameRequester, + // Whether to include the initial welcome banner on session configured + show_welcome_banner: bool, + // When resuming an existing session (selected via resume picker), avoid an + // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. + suppress_session_configured_redraw: bool, + // User messages queued while a turn is in progress + 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, + // Snapshot of token usage to restore after review mode exits. + pre_review_token_info: Option>, + // Whether to add a final message separator after the last message + needs_final_message_separator: bool, + + last_rendered_width: std::cell::Cell>, + // Feedback sink for /feedback + feedback: codex_feedback::CodexFeedback, + // Current session rollout path (if known) + current_rollout_path: Option, +} + +struct UserMessage { + text: String, + image_paths: Vec, +} + +impl From for UserMessage { + fn from(text: String) -> Self { + Self { + text, + image_paths: Vec::new(), + } + } +} + +impl From<&str> for UserMessage { + fn from(text: &str) -> Self { + Self { + text: text.to_string(), + image_paths: Vec::new(), + } + } +} + +fn create_initial_user_message(text: String, image_paths: Vec) -> Option { + if text.is_empty() && image_paths.is_empty() { + None + } else { + Some(UserMessage { text, image_paths }) + } +} + +impl ChatWidget { + fn flush_answer_stream_with_separator(&mut self) { + if let Some(mut controller) = self.stream_controller.take() + && let Some(cell) = controller.finalize() + { + self.add_boxed_history(cell); + } + } + + fn set_status_header(&mut self, header: String) { + self.current_status_header = header.clone(); + self.bottom_pane.update_status_header(header); + } + + fn restore_retry_status_header_if_present(&mut self) { + if let Some(header) = self.retry_status_header.take() + && self.current_status_header != header + { + self.set_status_header(header); + } + } + + // --- Small event handlers --- + fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { + self.bottom_pane + .set_history_metadata(event.history_log_id, event.history_entry_count); + self.conversation_id = Some(event.session_id); + self.current_rollout_path = Some(event.rollout_path.clone()); + let initial_messages = event.initial_messages.clone(); + let model_for_header = event.model.clone(); + self.session_header.set_model(&model_for_header); + self.add_to_history(history_cell::new_session_info( + &self.config, + &model_for_header, + event, + self.show_welcome_banner, + )); + if let Some(messages) = initial_messages { + self.replay_initial_messages(messages); + } + // Ask codex-core to enumerate custom prompts for this session. + self.submit_op(Op::ListCustomPrompts); + if let Some(user_message) = self.initial_user_message.take() { + self.submit_user_message(user_message); + } + if !self.suppress_session_configured_redraw { + self.request_redraw(); + } + } + + pub(crate) fn open_feedback_note( + &mut self, + category: crate::app_event::FeedbackCategory, + include_logs: bool, + ) { + // Build a fresh snapshot at the time of opening the note overlay. + let snapshot = self.feedback.snapshot(self.conversation_id); + let rollout = if include_logs { + self.current_rollout_path.clone() + } else { + None + }; + let view = crate::bottom_pane::FeedbackNoteView::new( + category, + snapshot, + rollout, + self.app_event_tx.clone(), + include_logs, + ); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + + pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { + let params = crate::bottom_pane::feedback_upload_consent_params( + self.app_event_tx.clone(), + category, + self.current_rollout_path.clone(), + ); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + + fn on_agent_message(&mut self, message: String) { + // 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(); + } + + fn on_agent_message_delta(&mut self, delta: String) { + self.handle_streaming_delta(delta); + } + + fn on_agent_reasoning_delta(&mut self, delta: String) { + // For reasoning deltas, do not stream to history. Accumulate the + // current reasoning block and extract the first bold element + // (between **/**) as the chunk header. Show this header as status. + self.reasoning_buffer.push_str(&delta); + + if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + // Update the shimmer header to the extracted reasoning chunk header. + self.set_status_header(header); + } else { + // Fallback while we don't yet have a bold header: leave existing header as-is. + } + self.request_redraw(); + } + + fn on_agent_reasoning_final(&mut self) { + let reasoning_summary_format = self.get_model_family().reasoning_summary_format; + // At the end of a reasoning block, record transcript-only content. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + if !self.full_reasoning_buffer.is_empty() { + let cell = history_cell::new_reasoning_summary_block( + self.full_reasoning_buffer.clone(), + reasoning_summary_format, + ); + self.add_boxed_history(cell); + } + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.request_redraw(); + } + + fn on_reasoning_section_break(&mut self) { + // Start a new reasoning block for header extraction and accumulate transcript. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + self.full_reasoning_buffer.push_str("\n\n"); + self.reasoning_buffer.clear(); + } + + // Raw reasoning uses the same flow as summarized reasoning + + fn on_task_started(&mut self) { + self.bottom_pane.clear_ctrl_c_quit_hint(); + self.bottom_pane.set_task_running(true); + self.retry_status_header = None; + self.bottom_pane.set_interrupt_hint_visible(true); + self.set_status_header(String::from("Working")); + self.full_reasoning_buffer.clear(); + self.reasoning_buffer.clear(); + self.request_redraw(); + } + + fn on_task_complete(&mut self, last_agent_message: Option) { + // If a stream is currently active, finalize it. + self.flush_answer_stream_with_separator(); + // Mark task stopped and request redraw now that all content is in history. + self.bottom_pane.set_task_running(false); + self.running_commands.clear(); + self.suppressed_exec_calls.clear(); + self.last_unified_wait = None; + self.request_redraw(); + + // If there is a queued user message, send exactly one now to begin the next turn. + self.maybe_send_next_queued_input(); + // Emit a notification when the turn completes (suppressed if focused). + self.notify(Notification::AgentTurnComplete { + response: last_agent_message.unwrap_or_default(), + }); + + self.maybe_show_pending_rate_limit_prompt(); + } + + pub(crate) fn set_token_info(&mut self, info: Option) { + match info { + Some(info) => self.apply_token_info(info), + None => { + self.bottom_pane.set_context_window(None, None); + self.token_info = None; + } + } + } + + fn apply_token_info(&mut self, info: TokenUsageInfo) { + let percent = self.context_remaining_percent(&info); + let used_tokens = self.context_used_tokens(&info, percent.is_some()); + self.bottom_pane.set_context_window(percent, used_tokens); + self.token_info = Some(info); + } + + fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { + info.model_context_window + .or(self.model_family.context_window) + .map(|window| { + info.last_token_usage + .percent_of_context_window_remaining(window) + }) + } + + fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option { + if percent_known { + return None; + } + + Some(info.total_token_usage.tokens_in_context_window()) + } + + fn restore_pre_review_token_info(&mut self) { + if let Some(saved) = self.pre_review_token_info.take() { + match saved { + Some(info) => self.apply_token_info(info), + None => { + self.bottom_pane.set_context_window(None, None); + self.token_info = None; + } + } + } + } + + pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { + if let Some(mut snapshot) = snapshot { + if snapshot.credits.is_none() { + snapshot.credits = self + .rate_limit_snapshot + .as_ref() + .and_then(|display| display.credits.as_ref()) + .map(|credits| CreditsSnapshot { + has_credits: credits.has_credits, + unlimited: credits.unlimited, + balance: credits.balance.clone(), + }); + } + + self.plan_type = snapshot.plan_type.or(self.plan_type); + + let warnings = self.rate_limit_warnings.take_warnings( + snapshot + .secondary + .as_ref() + .map(|window| window.used_percent), + snapshot + .secondary + .as_ref() + .and_then(|window| window.window_minutes), + snapshot.primary.as_ref().map(|window| window.used_percent), + snapshot + .primary + .as_ref() + .and_then(|window| window.window_minutes), + ); + + let high_usage = snapshot + .secondary + .as_ref() + .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false) + || snapshot + .primary + .as_ref() + .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false); + + if high_usage + && !self.rate_limit_switch_prompt_hidden() + && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG + && !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + ) + { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; + } + + let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now()); + self.rate_limit_snapshot = Some(display); + + if !warnings.is_empty() { + for warning in warnings { + self.add_to_history(history_cell::new_warning_event(warning)); + } + self.request_redraw(); + } + } else { + self.rate_limit_snapshot = None; + } + } + /// 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_cell_as_failed(); + // Reset running state and clear streaming buffers. + self.bottom_pane.set_task_running(false); + self.running_commands.clear(); + self.suppressed_exec_calls.clear(); + self.last_unified_wait = None; + self.stream_controller = None; + self.maybe_show_pending_rate_limit_prompt(); + } + pub(crate) fn get_model_family(&self) -> ModelFamily { + self.model_family.clone() + } + + fn on_error(&mut self, message: String) { + 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. + self.maybe_send_next_queued_input(); + } + + fn on_warning(&mut self, message: impl Into) { + self.add_to_history(history_cell::new_warning_event(message.into())); + self.request_redraw(); + } + + fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { + let mut status = self.mcp_startup_status.take().unwrap_or_default(); + if let McpStartupStatus::Failed { error } = &ev.status { + self.on_warning(error); + } + status.insert(ev.server, ev.status); + self.mcp_startup_status = Some(status); + self.bottom_pane.set_task_running(true); + if let Some(current) = &self.mcp_startup_status { + let total = current.len(); + let mut starting: Vec<_> = current + .iter() + .filter_map(|(name, state)| { + if matches!(state, McpStartupStatus::Starting) { + Some(name) + } else { + None + } + }) + .collect(); + starting.sort(); + if let Some(first) = starting.first() { + let completed = total.saturating_sub(starting.len()); + let max_to_show = 3; + let mut to_show: Vec = starting + .iter() + .take(max_to_show) + .map(ToString::to_string) + .collect(); + if starting.len() > max_to_show { + to_show.push("…".to_string()); + } + let header = if total > 1 { + format!( + "Starting MCP servers ({completed}/{total}): {}", + to_show.join(", ") + ) + } else { + format!("Booting MCP server: {first}") + }; + self.set_status_header(header); + } + } + self.request_redraw(); + } + + fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { + let mut parts = Vec::new(); + if !ev.failed.is_empty() { + let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); + parts.push(format!("failed: {}", failed_servers.join(", "))); + } + if !ev.cancelled.is_empty() { + self.on_warning(format!( + "MCP startup interrupted. The following servers were not initialized: {}", + ev.cancelled.join(", ") + )); + } + if !parts.is_empty() { + self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); + } + + self.mcp_startup_status = None; + self.bottom_pane.set_task_running(false); + self.maybe_send_next_queued_input(); + self.request_redraw(); + } + + /// 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, reason: TurnAbortReason) { + // Finalize, log a gentle prompt, and clear running state. + 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. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), + )); + } + + // If any messages were queued during the task, restore them into the composer. + if !self.queued_user_messages.is_empty() { + let queued_text = self + .queued_user_messages + .iter() + .map(|m| m.text.clone()) + .collect::>() + .join("\n"); + let existing_text = self.bottom_pane.composer_text(); + let combined = if existing_text.is_empty() { + queued_text + } else if queued_text.is_empty() { + existing_text + } else { + format!("{queued_text}\n{existing_text}") + }; + self.bottom_pane.set_composer_text(combined); + // Clear the queue and update the status indicator list. + self.queued_user_messages.clear(); + self.refresh_queued_user_messages(); + } + + self.request_redraw(); + } + + fn on_plan_update(&mut self, update: UpdatePlanArgs) { + self.add_to_history(history_cell::new_plan_update(update)); + } + + fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { + let id2 = id.clone(); + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_exec_approval(id, ev), + |s| s.handle_exec_approval_now(id2, ev2), + ); + } + + fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) { + let id2 = id.clone(); + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_apply_patch_approval(id, ev), + |s| s.handle_apply_patch_approval_now(id2, ev2), + ); + } + + fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_elicitation(ev), + |s| s.handle_elicitation_request_now(ev2), + ); + } + + fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { + self.flush_answer_stream_with_separator(); + let ev2 = ev.clone(); + self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); + } + + fn on_exec_command_output_delta( + &mut self, + _ev: codex_core::protocol::ExecCommandOutputDeltaEvent, + ) { + // TODO: Handle streaming exec output if/when implemented + } + + fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { + // TODO: Handle once design is ready + } + + fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { + self.add_to_history(history_cell::new_patch_event( + event.changes, + &self.config.cwd, + )); + } + + fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) { + self.flush_answer_stream_with_separator(); + self.add_to_history(history_cell::new_view_image_tool_call( + event.path, + &self.config.cwd, + )); + self.request_redraw(); + } + + fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) { + let ev2 = event.clone(); + self.defer_or_handle( + |q| q.push_patch_end(event), + |s| s.handle_patch_apply_end_now(ev2), + ); + } + + fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) { + let ev2 = ev.clone(); + self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); + } + + fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { + let ev2 = ev.clone(); + self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); + } + + fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) { + let ev2 = ev.clone(); + self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); + } + + fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { + self.flush_answer_stream_with_separator(); + } + + fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { + self.flush_answer_stream_with_separator(); + self.add_to_history(history_cell::new_web_search_call(format!( + "Searched: {}", + ev.query + ))); + } + + fn on_get_history_entry_response( + &mut self, + event: codex_core::protocol::GetHistoryEntryResponseEvent, + ) { + let codex_core::protocol::GetHistoryEntryResponseEvent { + offset, + log_id, + entry, + } = event; + self.bottom_pane + .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); + } + + fn on_shutdown_complete(&mut self) { + self.request_exit(); + } + + fn on_turn_diff(&mut self, unified_diff: String) { + debug!("TurnDiffEvent: {unified_diff}"); + } + + fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { + let DeprecationNoticeEvent { summary, details } = event; + self.add_to_history(history_cell::new_deprecation_notice(summary, details)); + self.request_redraw(); + } + + fn on_background_event(&mut self, message: String) { + debug!("BackgroundEvent: {message}"); + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane.set_interrupt_hint_visible(true); + self.set_status_header(message); + } + + fn on_undo_started(&mut self, event: UndoStartedEvent) { + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane.set_interrupt_hint_visible(false); + let message = event + .message + .unwrap_or_else(|| "Undo in progress...".to_string()); + self.set_status_header(message); + } + + fn on_undo_completed(&mut self, event: UndoCompletedEvent) { + let UndoCompletedEvent { success, message } = event; + self.bottom_pane.hide_status_indicator(); + let message = message.unwrap_or_else(|| { + if success { + "Undo completed successfully.".to_string() + } else { + "Undo failed.".to_string() + } + }); + if success { + self.add_info_message(message, None); + } else { + self.add_error_message(message); + } + } + + fn on_stream_error(&mut self, message: String) { + if self.retry_status_header.is_none() { + self.retry_status_header = Some(self.current_status_header.clone()); + } + self.set_status_header(message); + } + + /// 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) { + if let Some(controller) = self.stream_controller.as_mut() { + let (cell, is_idle) = controller.on_commit_tick(); + if let Some(cell) = cell { + self.bottom_pane.hide_status_indicator(); + self.add_boxed_history(cell); + } + if is_idle { + self.app_event_tx.send(AppEvent::StopCommitAnimation); + } + } + } + + fn flush_interrupt_queue(&mut self) { + let mut mgr = std::mem::take(&mut self.interrupts); + mgr.flush_all(self); + self.interrupts = mgr; + } + + #[inline] + fn defer_or_handle( + &mut self, + push: impl FnOnce(&mut InterruptManager), + handle: impl FnOnce(&mut Self), + ) { + // 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.stream_controller.is_some() || !self.interrupts.is_empty() { + push(&mut self.interrupts); + } else { + handle(self); + } + } + + fn handle_stream_finished(&mut self) { + if self.task_complete_pending { + self.bottom_pane.hide_status_indicator(); + 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_cell(); + + if self.stream_controller.is_none() { + if self.needs_final_message_separator { + let elapsed_seconds = self + .bottom_pane + .status_widget() + .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); + self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); + self.needs_final_message_separator = false; + } + self.stream_controller = Some(StreamController::new( + self.last_rendered_width.get().map(|w| w.saturating_sub(2)), + )); + } + if let Some(controller) = self.stream_controller.as_mut() + && controller.push(&delta) + { + self.app_event_tx.send(AppEvent::StartCommitAnimation); + } + self.request_redraw(); + } + + pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { + let running = self.running_commands.remove(&ev.call_id); + if self.suppressed_exec_calls.remove(&ev.call_id) { + return; + } + let (command, parsed, source) = match running { + Some(rc) => (rc.command, rc.parsed_cmd, rc.source), + None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), + }; + let is_unified_exec_interaction = + matches!(source, ExecCommandSource::UnifiedExecInteraction); + + let needs_new = self + .active_cell + .as_ref() + .map(|cell| cell.as_any().downcast_ref::().is_none()) + .unwrap_or(true); + if needs_new { + self.flush_active_cell(); + self.active_cell = Some(Box::new(new_active_exec_command( + ev.call_id.clone(), + command, + parsed, + source, + ev.interaction_input.clone(), + self.config.animations, + ))); + } + + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + { + let output = if is_unified_exec_interaction { + CommandOutput { + exit_code: ev.exit_code, + formatted_output: String::new(), + aggregated_output: String::new(), + } + } else { + CommandOutput { + exit_code: ev.exit_code, + formatted_output: ev.formatted_output.clone(), + aggregated_output: ev.aggregated_output.clone(), + } + }; + cell.complete_call(&ev.call_id, output, ev.duration); + if cell.should_flush() { + self.flush_active_cell(); + } + } + } + + pub(crate) fn handle_patch_apply_end_now( + &mut self, + event: codex_core::protocol::PatchApplyEndEvent, + ) { + // If the patch was successful, just let the "Edited" block stand. + // Otherwise, add a failure block. + if !event.success { + self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); + } + } + + pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { + self.flush_answer_stream_with_separator(); + let command = shlex::try_join(ev.command.iter().map(String::as_str)) + .unwrap_or_else(|_| ev.command.join(" ")); + self.notify(Notification::ExecApprovalRequested { command }); + + let request = ApprovalRequest::Exec { + id, + command: ev.command, + reason: ev.reason, + proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.request_redraw(); + } + + pub(crate) fn handle_apply_patch_approval_now( + &mut self, + id: String, + ev: ApplyPatchApprovalRequestEvent, + ) { + self.flush_answer_stream_with_separator(); + + let request = ApprovalRequest::ApplyPatch { + id, + reason: ev.reason, + changes: ev.changes.clone(), + cwd: self.config.cwd.clone(), + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.request_redraw(); + self.notify(Notification::EditApprovalRequested { + cwd: self.config.cwd.clone(), + changes: ev.changes.keys().cloned().collect(), + }); + } + + pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { + self.flush_answer_stream_with_separator(); + + self.notify(Notification::ElicitationRequested { + server_name: ev.server_name.clone(), + }); + + let request = ApprovalRequest::McpElicitation { + server_name: ev.server_name, + request_id: ev.id, + message: ev.message, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.request_redraw(); + } + + pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { + // Ensure the status indicator is visible while the command runs. + self.running_commands.insert( + ev.call_id.clone(), + RunningCommand { + command: ev.command.clone(), + parsed_cmd: ev.parsed_cmd.clone(), + source: ev.source, + }, + ); + let is_wait_interaction = matches!(ev.source, ExecCommandSource::UnifiedExecInteraction) + && ev + .interaction_input + .as_deref() + .map(str::is_empty) + .unwrap_or(true); + let command_display = ev.command.join(" "); + let should_suppress_unified_wait = is_wait_interaction + && self + .last_unified_wait + .as_ref() + .is_some_and(|wait| wait.is_duplicate(&command_display)); + if is_wait_interaction { + self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display)); + } else { + self.last_unified_wait = None; + } + if should_suppress_unified_wait { + self.suppressed_exec_calls.insert(ev.call_id); + return; + } + let interaction_input = ev.interaction_input.clone(); + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + && let Some(new_exec) = cell.with_added_call( + ev.call_id.clone(), + ev.command.clone(), + ev.parsed_cmd.clone(), + ev.source, + interaction_input.clone(), + ) + { + *cell = new_exec; + } else { + self.flush_active_cell(); + + self.active_cell = Some(Box::new(new_active_exec_command( + ev.call_id.clone(), + ev.command.clone(), + ev.parsed_cmd, + ev.source, + interaction_input, + self.config.animations, + ))); + } + + self.request_redraw(); + } + + pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( + ev.call_id, + ev.invocation, + self.config.animations, + ))); + self.request_redraw(); + } + pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { + self.flush_answer_stream_with_separator(); + + let McpToolCallEndEvent { + call_id, + invocation, + duration, + result, + } = ev; + + let extra_cell = match self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + { + Some(cell) if cell.call_id() == call_id => cell.complete(duration, result), + _ => { + self.flush_active_cell(); + let mut cell = history_cell::new_active_mcp_tool_call( + call_id, + invocation, + self.config.animations, + ); + let extra_cell = cell.complete(duration, result); + self.active_cell = Some(Box::new(cell)); + extra_cell + } + }; + + self.flush_active_cell(); + if let Some(extra) = extra_cell { + self.add_boxed_history(extra); + } + } + + pub(crate) fn new( + common: ChatWidgetInit, + conversation_manager: Arc, + ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_prompt, + initial_images, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + skills, + is_first_run, + model_family, + } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut config = config; + config.model = Some(model_slug.clone()); + let mut rng = rand::rng(); + let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); + + let mut widget = Self { + app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), + codex_op_tx, + bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + animations_enabled: config.animations, + skills, + }), + active_cell: None, + config, + model_family, + auth_manager, + models_manager, + session_header: SessionHeader::new(model_slug), + initial_user_message: create_initial_user_message( + initial_prompt.unwrap_or_default(), + initial_images, + ), + token_info: None, + rate_limit_snapshot: None, + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + stream_controller: None, + running_commands: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + task_complete_pending: false, + mcp_startup_status: None, + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, + conversation_id: None, + queued_user_messages: VecDeque::new(), + show_welcome_banner: is_first_run, + suppress_session_configured_redraw: false, + pending_notification: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), + feedback, + current_rollout_path: None, + }; + + widget.prefetch_rate_limits(); + + widget + } + + /// Create a ChatWidget attached to an existing conversation (e.g., a fork). + pub(crate) fn new_from_existing( + common: ChatWidgetInit, + conversation: std::sync::Arc, + session_configured: codex_core::protocol::SessionConfiguredEvent, + ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_prompt, + initial_images, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + skills, + model_family, + .. + } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut rng = rand::rng(); + let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + + let codex_op_tx = + spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + + let mut widget = Self { + app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), + codex_op_tx, + bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + animations_enabled: config.animations, + skills, + }), + active_cell: None, + config, + model_family, + auth_manager, + models_manager, + session_header: SessionHeader::new(model_slug), + initial_user_message: create_initial_user_message( + initial_prompt.unwrap_or_default(), + initial_images, + ), + token_info: None, + rate_limit_snapshot: None, + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + stream_controller: None, + running_commands: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + task_complete_pending: false, + mcp_startup_status: None, + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, + conversation_id: None, + queued_user_messages: VecDeque::new(), + show_welcome_banner: false, + suppress_session_configured_redraw: true, + pending_notification: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), + feedback, + current_rollout_path: None, + }; + + widget.prefetch_rate_limits(); + + widget + } + + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { + self.on_ctrl_c(); + return; + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && c.eq_ignore_ascii_case(&'v') => + { + match paste_image_to_temp_png() { + Ok((path, info)) => { + self.attach_image( + path, + info.width, + info.height, + info.encoded_format.label(), + ); + } + Err(err) => { + tracing::warn!("failed to paste image: {err}"); + self.add_to_history(history_cell::new_error_event(format!( + "Failed to paste image: {err}", + ))); + } + } + return; + } + other if other.kind == KeyEventKind::Press => { + self.bottom_pane.clear_ctrl_c_quit_hint(); + } + _ => {} + } + + match key_event { + KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::ALT, + kind: KeyEventKind::Press, + .. + } if !self.queued_user_messages.is_empty() => { + // Prefer the most recently queued item. + if let Some(user_message) = self.queued_user_messages.pop_back() { + self.bottom_pane.set_composer_text(user_message.text); + self.refresh_queued_user_messages(); + self.request_redraw(); + } + } + _ => { + match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted(text) => { + // If a task is running, queue the user input to be sent after the turn completes. + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::None => {} + } + } + } + } + + pub(crate) fn attach_image( + &mut self, + path: PathBuf, + width: u32, + height: u32, + format_label: &str, + ) { + tracing::info!( + "attach_image path={path:?} width={width} height={height} format={format_label}", + ); + self.bottom_pane + .attach_image(path, width, height, format_label); + self.request_redraw(); + } + + fn dispatch_command(&mut self, cmd: SlashCommand) { + if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + let message = format!( + "'/{}' is disabled while a task is in progress.", + cmd.command() + ); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + return; + } + match cmd { + SlashCommand::Feedback => { + // Step 1: pick a category (UI built in feedback_view) + let params = + crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + SlashCommand::New => { + self.app_event_tx.send(AppEvent::NewSession); + } + SlashCommand::Resume => { + self.app_event_tx.send(AppEvent::OpenResumePicker); + } + SlashCommand::Init => { + let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + if init_target.exists() { + let message = format!( + "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." + ); + self.add_info_message(message, None); + return; + } + const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); + self.submit_user_message(INIT_PROMPT.to_string().into()); + } + SlashCommand::Compact => { + self.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + } + SlashCommand::Review => { + self.open_review_popup(); + } + SlashCommand::Model => { + self.open_model_popup(); + } + SlashCommand::Approvals => { + self.open_approvals_popup(); + } + SlashCommand::Quit | SlashCommand::Exit => { + self.request_exit(); + } + SlashCommand::Logout => { + if let Err(e) = codex_core::auth::logout( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + tracing::error!("failed to logout: {e}"); + } + self.request_exit(); + } + SlashCommand::Undo => { + self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); + } + SlashCommand::Diff => { + self.add_diff_in_progress(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = match get_git_diff().await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + } + } + Err(e) => format!("Failed to compute diff: {e}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); + } + SlashCommand::Mention => { + self.insert_str("@"); + } + SlashCommand::Skills => { + self.insert_str("$"); + } + SlashCommand::Status => { + self.add_status_output(); + } + SlashCommand::Mcp => { + self.add_mcp_output(); + } + SlashCommand::Rollout => { + if let Some(path) = self.rollout_path() { + self.add_info_message( + format!("Current rollout path: {}", path.display()), + None, + ); + } else { + self.add_info_message("Rollout path is not available yet.".to_string(), None); + } + } + SlashCommand::TestApproval => { + use codex_core::protocol::EventMsg; + use std::collections::HashMap; + + use codex_core::protocol::ApplyPatchApprovalRequestEvent; + use codex_core::protocol::FileChange; + + self.app_event_tx.send(AppEvent::CodexEvent(Event { + id: "1".to_string(), + // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + // call_id: "1".to_string(), + // command: vec!["git".into(), "apply".into()], + // cwd: self.config.cwd.clone(), + // reason: Some("test".to_string()), + // }), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "1".to_string(), + turn_id: "turn-1".to_string(), + changes: HashMap::from([ + ( + PathBuf::from("/tmp/test.txt"), + FileChange::Add { + content: "test".to_string(), + }, + ), + ( + PathBuf::from("/tmp/test2.txt"), + FileChange::Update { + unified_diff: "+test\n-test2".to_string(), + move_path: None, + }, + ), + ]), + reason: None, + grant_root: Some(PathBuf::from("/tmp")), + }), + })); + } + } + } + + pub(crate) fn handle_paste(&mut self, text: String) { + self.bottom_pane.handle_paste(text); + } + + // Returns true if caller should skip rendering this frame (a future frame is scheduled). + pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { + if self.bottom_pane.flush_paste_burst_if_due() { + // A paste just flushed; request an immediate redraw and skip this frame. + self.request_redraw(); + true + } else if self.bottom_pane.is_in_paste_burst() { + // While capturing a burst, schedule a follow-up tick and skip this frame + // to avoid redundant renders between ticks. + frame_requester.schedule_frame_in( + crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), + ); + true + } else { + false + } + } + + fn flush_active_cell(&mut self) { + if let Some(active) = self.active_cell.take() { + self.needs_final_message_separator = true; + self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); + } + } + + fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { + self.add_boxed_history(Box::new(cell)); + } + + fn add_boxed_history(&mut self, cell: Box) { + if !cell.display_lines(u16::MAX).is_empty() { + // Only break exec grouping if the cell renders visible lines. + self.flush_active_cell(); + self.needs_final_message_separator = true; + } + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + } + + fn queue_user_message(&mut self, user_message: UserMessage) { + if self.bottom_pane.is_task_running() { + self.queued_user_messages.push_back(user_message); + self.refresh_queued_user_messages(); + } else { + self.submit_user_message(user_message); + } + } + + fn submit_user_message(&mut self, user_message: UserMessage) { + let UserMessage { text, image_paths } = user_message; + if text.is_empty() && image_paths.is_empty() { + return; + } + + let mut items: Vec = Vec::new(); + + // Special-case: "!cmd" executes a local shell command instead of sending to the model. + if let Some(stripped) = text.strip_prefix('!') { + let cmd = stripped.trim(); + if cmd.is_empty() { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + USER_SHELL_COMMAND_HELP_TITLE.to_string(), + Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), + ), + ))); + return; + } + self.submit_op(Op::RunUserShellCommand { + command: cmd.to_string(), + }); + return; + } + + if !text.is_empty() { + items.push(UserInput::Text { text: text.clone() }); + } + + for path in image_paths { + items.push(UserInput::LocalImage { path }); + } + + self.codex_op_tx + .send(Op::UserInput { items }) + .unwrap_or_else(|e| { + tracing::error!("failed to send message: {e}"); + }); + + // Persist the text to cross-session message history. + if !text.is_empty() { + self.codex_op_tx + .send(Op::AddToHistory { text: text.clone() }) + .unwrap_or_else(|e| { + tracing::error!("failed to send AddHistory op: {e}"); + }); + } + + // Only show the text portion in conversation history. + if !text.is_empty() { + self.add_to_history(history_cell::new_user_prompt(text)); + } + self.needs_final_message_separator = false; + } + + /// Replay a subset of initial events into the UI to seed the transcript when + /// resuming an existing session. This approximates the live event flow and + /// is intentionally conservative: only safe-to-replay items are rendered to + /// avoid triggering side effects. Event ids are passed as `None` to + /// distinguish replayed events from live ones. + fn replay_initial_messages(&mut self, events: Vec) { + for msg in events { + if matches!(msg, EventMsg::SessionConfigured(_)) { + continue; + } + // `id: None` indicates a synthetic/fake id coming from replay. + self.dispatch_event_msg(None, msg, true); + } + } + + pub(crate) fn handle_codex_event(&mut self, event: Event) { + let Event { id, msg } = event; + self.dispatch_event_msg(Some(id), msg, false); + } + + /// Dispatch a protocol `EventMsg` to the appropriate handler. + /// + /// `id` is `Some` for live events and `None` for replayed events from + /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id + /// that must not be used to correlate follow-up actions. + fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { + let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); + if !is_stream_error { + self.restore_retry_status_header_if_present(); + } + + match msg { + EventMsg::AgentMessageDelta(_) + | EventMsg::AgentReasoningDelta(_) + | EventMsg::TerminalInteraction(_) + | EventMsg::ExecCommandOutputDelta(_) => {} + _ => { + tracing::trace!("handle_codex_event: {:?}", msg); + } + } + + match msg { + EventMsg::SessionConfigured(e) => self.on_session_configured(e), + EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), + EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { + self.on_agent_message_delta(delta) + } + EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) + | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { + delta, + }) => self.on_agent_reasoning_delta(delta), + EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), + EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { + self.on_agent_reasoning_delta(text); + self.on_agent_reasoning_final(); + } + EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), + EventMsg::TaskStarted(_) => self.on_task_started(), + EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { + self.on_task_complete(last_agent_message) + } + EventMsg::TokenCount(ev) => { + self.set_token_info(ev.info); + self.on_rate_limit_snapshot(ev.rate_limits); + } + EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message), + EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), + EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), + EventMsg::TurnAborted(ev) => match ev.reason { + TurnAbortReason::Interrupted => { + 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) => { + // For replayed events, synthesize an empty id (these should not occur). + self.on_exec_approval_request(id.unwrap_or_default(), ev) + } + EventMsg::ApplyPatchApprovalRequest(ev) => { + self.on_apply_patch_approval_request(id.unwrap_or_default(), ev) + } + EventMsg::ElicitationRequest(ev) => { + self.on_elicitation_request(ev); + } + EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), + EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), + EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), + EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), + EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), + EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), + EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev), + EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), + EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), + EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), + EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), + EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), + EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), + EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), + EventMsg::ShutdownComplete => self.on_shutdown_complete(), + EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), + EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), + EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { + self.on_background_event(message) + } + EventMsg::UndoStarted(ev) => self.on_undo_started(ev), + EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), + EventMsg::StreamError(StreamErrorEvent { message, .. }) => { + self.on_stream_error(message) + } + EventMsg::UserMessage(ev) => { + if from_replay { + self.on_user_message_event(ev); + } + } + EventMsg::EnteredReviewMode(review_request) => { + self.on_entered_review_mode(review_request) + } + EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), + EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), + EventMsg::RawResponseItem(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) + | EventMsg::AgentMessageContentDelta(_) + | EventMsg::ReasoningContentDelta(_) + | EventMsg::ReasoningRawContentDelta(_) => {} + } + } + + fn on_entered_review_mode(&mut self, review: ReviewRequest) { + // Enter review mode and emit a concise banner + if self.pre_review_token_info.is_none() { + self.pre_review_token_info = Some(self.token_info.clone()); + } + self.is_review_mode = true; + let hint = review + .user_facing_hint + .unwrap_or_else(|| codex_core::review_prompts::user_facing_hint(&review.target)); + let banner = format!(">> Code review started: {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_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, None, &mut rendered); + 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, None, &mut message_lines); + let body_cell = AgentMessageCell::new(message_lines, true); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); + } + } + + self.is_review_mode = false; + self.restore_pre_review_token_info(); + // 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) { + let message = event.message.trim(); + if !message.is_empty() { + self.add_to_history(history_cell::new_user_prompt(message.to_string())); + } + } + + fn request_exit(&self) { + self.app_event_tx.send(AppEvent::ExitRequest); + } + + fn request_redraw(&mut self) { + self.frame_requester.schedule_frame(); + } + + fn notify(&mut self, notification: Notification) { + if !notification.allowed_for(&self.config.tui_notifications) { + return; + } + self.pending_notification = Some(notification); + self.request_redraw(); + } + + pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { + if let Some(notif) = self.pending_notification.take() { + tui.notify(notif.display()); + } + } + + /// Mark the active cell as failed (✗) and flush it into history. + fn finalize_active_cell_as_failed(&mut self) { + if let Some(mut cell) = self.active_cell.take() { + // Insert finalized cell into history and keep grouping consistent. + if let Some(exec) = cell.as_any_mut().downcast_mut::() { + exec.mark_failed(); + } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { + tool.mark_failed(); + } + self.add_boxed_history(cell); + } + } + + // If idle and there are queued inputs, submit exactly one to start the next turn. + fn maybe_send_next_queued_input(&mut self) { + if self.bottom_pane.is_task_running() { + return; + } + if let Some(user_message) = self.queued_user_messages.pop_front() { + self.submit_user_message(user_message); + } + // Update the list to reflect the remaining queued messages (if any). + self.refresh_queued_user_messages(); + } + + /// Rebuild and update the queued user messages from the current queue. + fn refresh_queued_user_messages(&mut self) { + let messages: Vec = self + .queued_user_messages + .iter() + .map(|m| m.text.clone()) + .collect(); + self.bottom_pane.set_queued_user_messages(messages); + } + + pub(crate) fn add_diff_in_progress(&mut self) { + self.request_redraw(); + } + + pub(crate) fn on_diff_complete(&mut self) { + self.request_redraw(); + } + + pub(crate) fn add_status_output(&mut self) { + let default_usage = TokenUsage::default(); + let (total_usage, context_usage) = if let Some(ti) = &self.token_info { + (&ti.total_token_usage, Some(&ti.last_token_usage)) + } else { + (&default_usage, Some(&default_usage)) + }; + self.add_to_history(crate::status::new_status_output( + &self.config, + self.auth_manager.as_ref(), + &self.model_family, + total_usage, + context_usage, + &self.conversation_id, + self.rate_limit_snapshot.as_ref(), + self.plan_type, + Local::now(), + self.model_family.get_model_slug(), + )); + } + fn stop_rate_limit_poller(&mut self) { + if let Some(handle) = self.rate_limit_poller.take() { + handle.abort(); + } + } + + fn prefetch_rate_limits(&mut self) { + self.stop_rate_limit_poller(); + + let Some(auth) = self.auth_manager.auth() else { + return; + }; + if auth.mode != AuthMode::ChatGPT { + return; + } + + let base_url = self.config.chatgpt_base_url.clone(); + let app_event_tx = self.app_event_tx.clone(); + + let handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + + loop { + if let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth.clone()).await { + app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot)); + } + interval.tick().await; + } + }); + + self.rate_limit_poller = Some(handle); + } + + fn lower_cost_preset(&self) -> Option { + let models = self.models_manager.try_list_models().ok()?; + models + .iter() + .find(|preset| preset.model == NUDGE_MODEL_SLUG) + .cloned() + } + + fn rate_limit_switch_prompt_hidden(&self) -> bool { + self.config + .notices + .hide_rate_limit_model_nudge + .unwrap_or(false) + } + + fn maybe_show_pending_rate_limit_prompt(&mut self) { + if self.rate_limit_switch_prompt_hidden() { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + return; + } + if !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ) { + return; + } + if let Some(preset) = self.lower_cost_preset() { + self.open_rate_limit_switch_prompt(preset); + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; + } else { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } + + fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { + let switch_model = preset.model.to_string(); + let display_name = preset.display_name.to_string(); + let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + + let switch_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(switch_model.clone()), + effort: Some(Some(default_effort)), + summary: None, + })); + tx.send(AppEvent::UpdateModel(switch_model.clone())); + tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); + })]; + + let keep_actions: Vec = Vec::new(); + let never_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); + tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); + })]; + let description = if preset.description.is_empty() { + Some("Uses fewer credits for upcoming turns.".to_string()) + } else { + Some(preset.description) + }; + + let items = vec![ + SelectionItem { + name: format!("Switch to {display_name}"), + description, + selected_description: None, + is_current: false, + actions: switch_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current model".to_string(), + description: None, + selected_description: None, + is_current: false, + actions: keep_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current model (never show again)".to_string(), + description: Some( + "Hide future rate limit reminders about switching models.".to_string(), + ), + selected_description: None, + is_current: false, + actions: never_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Approaching rate limits".to_string()), + subtitle: Some(format!("Switch to {display_name} for lower credit usage?")), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + /// Open a popup to choose a quick auto model. Selecting "All models" + /// opens the full picker with every available preset. + pub(crate) fn open_model_popup(&mut self) { + let current_model = self.model_family.get_model_slug().to_string(); + let presets: Vec = + // todo(aibrahim): make this async function + match self.models_manager.try_list_models() { + Ok(models) => models, + Err(_) => { + self.add_info_message( + "Models are being updated; please try /model again in a moment." + .to_string(), + None, + ); + return; + } + }; + + let current_label = presets + .iter() + .find(|preset| preset.model == current_model) + .map(|preset| preset.display_name.to_string()) + .unwrap_or_else(|| current_model.clone()); + + let (mut auto_presets, other_presets): (Vec, Vec) = presets + .into_iter() + .partition(|preset| Self::is_auto_model(&preset.model)); + + if auto_presets.is_empty() { + self.open_all_models_popup(other_presets); + return; + } + + auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); + + let mut items: Vec = auto_presets + .into_iter() + .map(|preset| { + let description = + (!preset.description.is_empty()).then_some(preset.description.clone()); + let model = preset.model.clone(); + let actions = Self::model_selection_actions( + model.clone(), + Some(preset.default_reasoning_effort), + ); + SelectionItem { + name: preset.display_name, + description, + is_current: model == current_model, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + if !other_presets.is_empty() { + let all_models = other_presets; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAllModelsPopup { + models: all_models.clone(), + }); + })]; + + let is_current = !items.iter().any(|item| item.is_current); + let description = Some(format!( + "Choose a specific model and reasoning level (current: {current_label})" + )); + + items.push(SelectionItem { + name: "All models".to_string(), + description, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Model".to_string()), + subtitle: Some("Pick a quick auto mode or browse all models.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn is_auto_model(model: &str) -> bool { + model.starts_with("codex-auto-") + } + + fn auto_model_order(model: &str) -> usize { + match model { + "codex-auto-fast" => 0, + "codex-auto-balanced" => 1, + "codex-auto-thorough" => 2, + _ => 3, + } + } + + pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { + if presets.is_empty() { + self.add_info_message( + "No additional models are available right now.".to_string(), + None, + ); + return; + } + + let current_model = self.model_family.get_model_slug().to_string(); + let mut items: Vec = Vec::new(); + for preset in presets.into_iter() { + let description = + (!preset.description.is_empty()).then_some(preset.description.to_string()); + let is_current = preset.model == current_model; + let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; + let preset_for_action = preset.clone(); + let actions: Vec = vec![Box::new(move |tx| { + let preset_for_event = preset_for_action.clone(); + tx.send(AppEvent::OpenReasoningPopup { + model: preset_for_event, + }); + })]; + items.push(SelectionItem { + name: preset.display_name.to_string(), + description, + is_current, + actions, + dismiss_on_select: single_supported_effort, + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Model and Effort".to_string()), + subtitle: Some( + "Access legacy models by running codex -m or in your config.toml" + .to_string(), + ), + footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()), + items, + ..Default::default() + }); + } + + fn model_selection_actions( + model_for_action: String, + effort_for_action: Option, + ) -> Vec { + vec![Box::new(move |tx| { + let effort_label = effort_for_action + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "default".to_string()); + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model_for_action.clone()), + effort: Some(effort_for_action), + summary: None, + })); + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: effort_for_action, + }); + tracing::info!( + "Selected model: {}, Selected effort: {}", + model_for_action, + effort_label + ); + })] + } + + /// Open a popup to choose the reasoning effort (stage 2) for the given model. + pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { + let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + let supported = preset.supported_reasoning_efforts; + + let warn_effort = if supported + .iter() + .any(|option| option.effort == ReasoningEffortConfig::XHigh) + { + Some(ReasoningEffortConfig::XHigh) + } else if supported + .iter() + .any(|option| option.effort == ReasoningEffortConfig::High) + { + Some(ReasoningEffortConfig::High) + } else { + None + }; + let warning_text = warn_effort.map(|effort| { + let effort_label = Self::reasoning_effort_label(effort); + format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") + }); + let warn_for_model = preset.model.starts_with("gpt-5.1-codex") + || preset.model.starts_with("gpt-5.1-codex-max"); + + struct EffortChoice { + stored: Option, + display: ReasoningEffortConfig, + } + let mut choices: Vec = Vec::new(); + for effort in ReasoningEffortConfig::iter() { + if supported.iter().any(|option| option.effort == effort) { + choices.push(EffortChoice { + stored: Some(effort), + display: effort, + }); + } + } + if choices.is_empty() { + choices.push(EffortChoice { + stored: Some(default_effort), + display: default_effort, + }); + } + + if choices.len() == 1 { + if let Some(effort) = choices.first().and_then(|c| c.stored) { + self.apply_model_and_effort(preset.model, Some(effort)); + } else { + self.apply_model_and_effort(preset.model, None); + } + return; + } + + let default_choice: Option = choices + .iter() + .any(|choice| choice.stored == Some(default_effort)) + .then_some(Some(default_effort)) + .flatten() + .or_else(|| choices.iter().find_map(|choice| choice.stored)) + .or(Some(default_effort)); + + let model_slug = preset.model.to_string(); + let is_current_model = self.model_family.get_model_slug() == preset.model; + let highlight_choice = if is_current_model { + self.config.model_reasoning_effort + } else { + default_choice + }; + let selection_choice = highlight_choice.or(default_choice); + let initial_selected_idx = choices + .iter() + .position(|choice| choice.stored == selection_choice) + .or_else(|| { + selection_choice + .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) + }); + let mut items: Vec = Vec::new(); + for choice in choices.iter() { + let effort = choice.display; + let mut effort_label = Self::reasoning_effort_label(effort).to_string(); + if choice.stored == default_choice { + effort_label.push_str(" (default)"); + } + + let description = choice + .stored + .and_then(|effort| { + supported + .iter() + .find(|option| option.effort == effort) + .map(|option| option.description.to_string()) + }) + .filter(|text| !text.is_empty()); + + let show_warning = warn_for_model && warn_effort == Some(effort); + let selected_description = if show_warning { + warning_text.as_ref().map(|warning_message| { + description.as_ref().map_or_else( + || warning_message.clone(), + |d| format!("{d}\n{warning_message}"), + ) + }) + } else { + None + }; + + let model_for_action = model_slug.clone(); + let actions = Self::model_selection_actions(model_for_action, choice.stored); + + items.push(SelectionItem { + name: effort_label, + description, + selected_description, + is_current: is_current_model && choice.stored == highlight_choice, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + let mut header = ColumnRenderable::new(); + header.push(Line::from( + format!("Select Reasoning Level for {model_slug}").bold(), + )); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + }); + } + + fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { + match effort { + ReasoningEffortConfig::None => "None", + ReasoningEffortConfig::Minimal => "Minimal", + ReasoningEffortConfig::Low => "Low", + ReasoningEffortConfig::Medium => "Medium", + ReasoningEffortConfig::High => "High", + ReasoningEffortConfig::XHigh => "Extra high", + } + } + + fn apply_model_and_effort(&self, model: String, effort: Option) { + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model.clone()), + effort: Some(effort), + summary: None, + })); + self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + self.app_event_tx.send(AppEvent::PersistModelSelection { + model: model.clone(), + effort, + }); + tracing::info!( + "Selected model: {}, Selected effort: {}", + model, + effort + .map(|e| e.to_string()) + .unwrap_or_else(|| "default".to_string()) + ); + } + + /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). + pub(crate) fn open_approvals_popup(&mut self) { + let current_approval = self.config.approval_policy; + let current_sandbox = self.config.sandbox_policy.clone(); + let mut items: Vec = Vec::new(); + let presets: Vec = builtin_approval_presets(); + for preset in presets.into_iter() { + let is_current = + Self::preset_matches_current(current_approval, ¤t_sandbox, &preset); + let name = preset.label.to_string(); + let description_text = preset.description; + let description = Some(description_text.to_string()); + let requires_confirmation = preset.id == "full-access" + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false); + let actions: Vec = if requires_confirmation { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenFullAccessConfirmation { + preset: preset_clone.clone(), + }); + })] + } else if preset.id == "auto" { + #[cfg(target_os = "windows")] + { + if codex_core::get_platform_sandbox().is_none() { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset_clone.clone(), + }); + })] + } else if let Some((sample_paths, extra_count, failed_scan)) = + self.world_writable_warning_details() + { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset_clone.clone()), + sample_paths: sample_paths.clone(), + extra_count, + failed_scan, + }); + })] + } else { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + } + } + #[cfg(not(target_os = "windows"))] + { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + } + } else { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }; + items.push(SelectionItem { + name, + description, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(()), + ..Default::default() + }); + } + + fn approval_preset_actions( + approval: AskForApproval, + sandbox: SandboxPolicy, + ) -> Vec { + vec![Box::new(move |tx| { + let sandbox_clone = sandbox.clone(); + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(approval), + sandbox_policy: Some(sandbox_clone.clone()), + model: None, + effort: None, + summary: None, + })); + tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); + tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); + })] + } + + fn preset_matches_current( + current_approval: AskForApproval, + current_sandbox: &SandboxPolicy, + preset: &ApprovalPreset, + ) -> bool { + if current_approval != preset.approval { + return false; + } + matches!( + (&preset.sandbox, current_sandbox), + (SandboxPolicy::ReadOnly, SandboxPolicy::ReadOnly) + | ( + SandboxPolicy::DangerFullAccess, + SandboxPolicy::DangerFullAccess + ) + | ( + SandboxPolicy::WorkspaceWrite { .. }, + SandboxPolicy::WorkspaceWrite { .. } + ) + ) + } + + #[cfg(target_os = "windows")] + pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { + if self + .config + .notices + .hide_world_writable_warning + .unwrap_or(false) + { + return None; + } + let cwd = self.config.cwd.clone(); + let env_map: std::collections::HashMap = std::env::vars().collect(); + match codex_windows_sandbox::apply_world_writable_scan_and_denies( + self.config.codex_home.as_path(), + cwd.as_path(), + &env_map, + &self.config.sandbox_policy, + Some(self.config.codex_home.as_path()), + ) { + Ok(_) => None, + Err(_) => Some((Vec::new(), 0, true)), + } + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { + None + } + + pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { + let approval = preset.approval; + let sandbox = preset.sandbox; + let mut header_children: Vec> = Vec::new(); + let title_line = Line::from("Enable full access?").bold(); + let info_line = Line::from(vec![ + "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " + .into(), + "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." + .fg(Color::Red), + ]); + header_children.push(Box::new(title_line)); + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + let header = ColumnRenderable::with(header_children); + + let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + })); + + let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + tx.send(AppEvent::PersistFullAccessWarningAcknowledged); + })); + + let deny_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })]; + + let items = vec![ + SelectionItem { + name: "Yes, continue anyway".to_string(), + description: Some("Apply full access for this session".to_string()), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again".to_string(), + description: Some("Enable full access and remember this choice".to_string()), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Go back without enabling full access".to_string()), + actions: deny_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(target_os = "windows")] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + preset: Option, + sample_paths: Vec, + extra_count: usize, + failed_scan: bool, + ) { + let (approval, sandbox) = match &preset { + Some(p) => (Some(p.approval), Some(p.sandbox.clone())), + None => (None, None), + }; + let mut header_children: Vec> = Vec::new(); + let describe_policy = |policy: &SandboxPolicy| match policy { + SandboxPolicy::WorkspaceWrite { .. } => "Agent mode", + SandboxPolicy::ReadOnly => "Read-Only mode", + _ => "Agent mode", + }; + let mode_label = preset + .as_ref() + .map(|p| describe_policy(&p.sandbox)) + .unwrap_or_else(|| describe_policy(&self.config.sandbox_policy)); + let info_line = if failed_scan { + Line::from(vec![ + "We couldn't complete the world-writable scan, so protections cannot be verified. " + .into(), + format!("The Windows sandbox cannot guarantee protection in {mode_label}.") + .fg(Color::Red), + ]) + } else { + Line::from(vec![ + "The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(), + " Consider removing write access for Everyone from the following folders:".into(), + ]) + }; + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + + if !sample_paths.is_empty() { + // Show up to three examples and optionally an "and X more" line. + let mut lines: Vec = Vec::new(); + lines.push(Line::from("")); + for p in &sample_paths { + lines.push(Line::from(format!(" - {p}"))); + } + if extra_count > 0 { + lines.push(Line::from(format!("and {extra_count} more"))); + } + header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + } + let header = ColumnRenderable::with(header_children); + + // Build actions ensuring acknowledgement happens before applying the new sandbox policy, + // so downstream policy-change hooks don't re-trigger the warning. + let mut accept_actions: Vec = Vec::new(); + // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals), + // to avoid duplicate warnings from the ensuing policy change. + if preset.is_some() { + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::SkipNextWorldWritableScan); + })); + } + if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { + accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); + } + + let mut accept_and_remember_actions: Vec = Vec::new(); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); + tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); + })); + if let (Some(approval), Some(sandbox)) = (approval, sandbox) { + accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); + } + + let items = vec![ + SelectionItem { + name: "Continue".to_string(), + description: Some(format!("Apply {mode_label} for this session")), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Continue and don't warn again".to_string(), + description: Some(format!("Enable {mode_label} and remember this choice")), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + _preset: Option, + _sample_paths: Vec, + _extra_count: usize, + _failed_scan: bool, + ) { + } + + #[cfg(target_os = "windows")] + pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { + use ratatui_macros::line; + + let mut header = ColumnRenderable::new(); + header.push(*Box::new( + Paragraph::new(vec![ + line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()], + line![ + "Learn more: https://developers.openai.com/codex/windows" + ], + ]) + .wrap(Wrap { trim: false }), + )); + + let preset_clone = preset; + let items = vec![ + SelectionItem { + name: "Enable experimental sandbox".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset_clone.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Go back".to_string(), + description: None, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + + #[cfg(target_os = "windows")] + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { + if self.config.forced_auto_mode_downgraded_on_windows + && codex_core::get_platform_sandbox().is_none() + && let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + { + self.open_windows_sandbox_enable_prompt(preset); + } + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {} + + #[cfg(target_os = "windows")] + pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) { + self.config.forced_auto_mode_downgraded_on_windows = false; + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {} + + /// Set the approval policy in the widget's config copy. + pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { + self.config.approval_policy = policy; + } + + /// Set the sandbox policy in the widget's config copy. + pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) { + #[cfg(target_os = "windows")] + let should_clear_downgrade = !matches!(policy, SandboxPolicy::ReadOnly) + || codex_core::get_platform_sandbox().is_some(); + + self.config.sandbox_policy = policy; + + #[cfg(target_os = "windows")] + if should_clear_downgrade { + self.config.forced_auto_mode_downgraded_on_windows = false; + } + } + + pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_full_access_warning = Some(acknowledged); + } + + pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_world_writable_warning = Some(acknowledged); + } + + pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { + self.config.notices.hide_rate_limit_model_nudge = Some(hidden); + if hidden { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn world_writable_warning_hidden(&self) -> bool { + self.config + .notices + .hide_world_writable_warning + .unwrap_or(false) + } + + /// Set the reasoning effort in the widget's config copy. + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + self.config.model_reasoning_effort = effort; + } + + /// Set the model in the widget's config copy. + pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { + self.session_header.set_model(model); + self.model_family = model_family; + } + + pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { + self.add_to_history(history_cell::new_info_event(message, hint)); + self.request_redraw(); + } + + pub(crate) fn add_plain_history_lines(&mut self, lines: Vec>) { + self.add_boxed_history(Box::new(PlainHistoryCell::new(lines))); + self.request_redraw(); + } + + pub(crate) fn add_error_message(&mut self, message: String) { + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + } + + pub(crate) fn add_mcp_output(&mut self) { + if self.config.mcp_servers.is_empty() { + self.add_to_history(history_cell::empty_mcp_output()); + } else { + self.submit_op(Op::ListMcpTools); + } + } + + /// Forward file-search results to the bottom pane. + pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { + self.bottom_pane.on_file_search_result(query, matches); + } + + /// Handle Ctrl-C key press. + fn on_ctrl_c(&mut self) { + if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { + return; + } + + if self.bottom_pane.is_task_running() { + self.bottom_pane.show_ctrl_c_quit_hint(); + self.submit_op(Op::Interrupt); + return; + } + + self.submit_op(Op::Shutdown); + } + + pub(crate) fn composer_is_empty(&self) -> bool { + self.bottom_pane.composer_is_empty() + } + + /// True when the UI is in the regular composer state with no running task, + /// no modal overlay (e.g. approvals or status indicator), and no composer popups. + /// In this state Esc-Esc backtracking is enabled. + pub(crate) fn is_normal_backtrack_mode(&self) -> bool { + self.bottom_pane.is_normal_backtrack_mode() + } + + pub(crate) fn insert_str(&mut self, text: &str) { + self.bottom_pane.insert_str(text); + } + + /// Replace the composer content with the provided text and reset cursor. + pub(crate) fn set_composer_text(&mut self, text: String) { + self.bottom_pane.set_composer_text(text); + } + + pub(crate) fn show_esc_backtrack_hint(&mut self) { + self.bottom_pane.show_esc_backtrack_hint(); + } + + pub(crate) fn clear_esc_backtrack_hint(&mut self) { + self.bottom_pane.clear_esc_backtrack_hint(); + } + /// Forward an `Op` directly to codex. + pub(crate) fn submit_op(&self, op: Op) { + // Record outbound operation for session replay fidelity. + crate::session_log::log_outbound_op(&op); + if let Err(e) = self.codex_op_tx.send(op) { + tracing::error!("failed to submit op: {e}"); + } + } + + fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { + self.add_to_history(history_cell::new_mcp_tools_output( + &self.config, + ev.tools, + ev.resources, + ev.resource_templates, + &ev.auth_statuses, + )); + } + + fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { + let len = ev.custom_prompts.len(); + debug!("received {len} custom prompts"); + // Forward to bottom pane so the slash popup can show them now. + 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 against a base branch".to_string(), + description: Some("(PR Style)".into()), + actions: vec![Box::new({ + let cwd = self.config.cwd.clone(); + move |tx| { + tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Review uncommitted changes".to_string(), + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: None, + }, + })); + })], + dismiss_on_select: true, + ..Default::default() + }); + + // New: Review a specific commit (opens commit picker) + items.push(SelectionItem { + name: "Review a commit".to_string(), + actions: vec![Box::new({ + let cwd = self.config.cwd.clone(); + move |tx| { + tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Custom review instructions".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenReviewCustomPrompt); + })], + dismiss_on_select: false, + ..Default::default() + }); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a review preset".into()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..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}"), + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: branch.clone(), + }, + user_facing_hint: None, + }, + })); + })], + dismiss_on_select: true, + search_value: Some(option), + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a base branch".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search branches".to_string()), + ..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 search_val = format!("{subject} {sha}"); + + items.push(SelectionItem { + name: subject.clone(), + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), + }, + user_facing_hint: None, + }, + })); + })], + dismiss_on_select: true, + search_value: Some(search_val), + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a commit to review".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search commits".to_string()), + ..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, + 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 { + target: ReviewTarget::Custom { + instructions: trimmed, + }, + user_facing_hint: None, + }, + })); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(crate) fn token_usage(&self) -> TokenUsage { + self.token_info + .as_ref() + .map(|ti| ti.total_token_usage.clone()) + .unwrap_or_default() + } + + pub(crate) fn conversation_id(&self) -> Option { + self.conversation_id + } + + pub(crate) fn rollout_path(&self) -> Option { + self.current_rollout_path.clone() + } + + /// Return a reference to the widget's current config (includes any + /// runtime overrides applied via TUI, e.g., model or approval policy). + pub(crate) fn config_ref(&self) -> &Config { + &self.config + } + + pub(crate) fn clear_token_usage(&mut self) { + self.token_info = None; + } + + fn as_renderable(&self) -> RenderableItem<'_> { + let active_cell_renderable = match &self.active_cell { + Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)), + None => RenderableItem::Owned(Box::new(())), + }; + let mut flex = FlexRenderable::new(); + flex.push(1, active_cell_renderable); + flex.push( + 0, + RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)), + ); + RenderableItem::Owned(Box::new(flex)) + } +} + +impl Drop for ChatWidget { + fn drop(&mut self) { + self.stop_rate_limit_poller(); + } +} + +impl Renderable for ChatWidget { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); + self.last_rendered_width.set(Some(area.width as usize)); + } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_renderable().cursor_pos(area) + } +} + +enum Notification { + AgentTurnComplete { response: String }, + ExecApprovalRequested { command: String }, + EditApprovalRequested { cwd: PathBuf, changes: Vec }, + ElicitationRequested { server_name: String }, +} + +impl Notification { + fn display(&self) -> String { + match self { + 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)) + } + Notification::EditApprovalRequested { cwd, changes } => { + format!( + "Codex wants to edit {}", + if changes.len() == 1 { + #[allow(clippy::unwrap_used)] + display_path_for(changes.first().unwrap(), cwd) + } else { + format!("{} files", changes.len()) + } + ) + } + Notification::ElicitationRequested { server_name } => { + format!("Approval requested by {server_name}") + } + } + } + + fn type_name(&self) -> &str { + match self { + Notification::AgentTurnComplete { .. } => "agent-turn-complete", + Notification::ExecApprovalRequested { .. } + | Notification::EditApprovalRequested { .. } + | Notification::ElicitationRequested { .. } => "approval-requested", + } + } + + fn allowed_for(&self, settings: &Notifications) -> bool { + match settings { + Notifications::Enabled(enabled) => *enabled, + 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", + "Implement {feature}", + "Find and fix a bug in @filename", + "Write tests for @filename", + "Improve documentation in @filename", +]; + +// Extract the first bold (Markdown) element in the form **...** from `s`. +// Returns the inner text if found; otherwise `None`. +fn extract_first_bold(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut i = 0usize; + while i + 1 < bytes.len() { + if bytes[i] == b'*' && bytes[i + 1] == b'*' { + let start = i + 2; + let mut j = start; + while j + 1 < bytes.len() { + if bytes[j] == b'*' && bytes[j + 1] == b'*' { + // Found closing ** + let inner = &s[start..j]; + let trimmed = inner.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } else { + return None; + } + } + j += 1; + } + // No closing; stop searching (wait for more deltas) + return None; + } + i += 1; + } + None +} + +async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { + match BackendClient::from_auth(base_url, &auth).await { + Ok(client) => match client.get_rate_limits().await { + Ok(snapshot) => Some(snapshot), + Err(err) => { + debug!(error = ?err, "failed to fetch rate limits from /usage"); + None + } + }, + Err(err) => { + debug!(error = ?err, "failed to construct backend client for rate limits"); + 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 search_val = format!("{subject} {sha}"); + + items.push(SelectionItem { + name: subject.clone(), + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), + }, + user_facing_hint: None, + }, + })); + })], + dismiss_on_select: true, + search_value: Some(search_val), + ..Default::default() + }); + } + + chat.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a commit to review".to_string()), + footer_hint: Some(standard_popup_hint_line()), + 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/tui2/src/chatwidget/agent.rs b/codex-rs/tui2/src/chatwidget/agent.rs new file mode 100644 index 00000000000..240972347fb --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/agent.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use codex_core::CodexConversation; +use codex_core::ConversationManager; +use codex_core::NewConversation; +use codex_core::config::Config; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::unbounded_channel; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +/// Spawn the agent bootstrapper and op forwarding loop, returning the +/// `UnboundedSender` used by the UI to submit operations. +pub(crate) fn spawn_agent( + config: Config, + app_event_tx: AppEventSender, + server: Arc, +) -> UnboundedSender { + let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); + + let app_event_tx_clone = app_event_tx; + tokio::spawn(async move { + let NewConversation { + conversation_id: _, + conversation, + session_configured, + } = match server.new_conversation(config).await { + Ok(v) => v, + #[allow(clippy::print_stderr)] + Err(err) => { + let message = err.to_string(); + eprintln!("{message}"); + app_event_tx_clone.send(AppEvent::CodexEvent(Event { + id: "".to_string(), + msg: EventMsg::Error(err.to_error_event(None)), + })); + app_event_tx_clone.send(AppEvent::ExitRequest); + tracing::error!("failed to initialize codex: {err}"); + return; + } + }; + + // Forward the captured `SessionConfigured` event so it can be rendered in the UI. + let ev = codex_core::protocol::Event { + // The `id` does not matter for rendering, so we can use a fake value. + id: "".to_string(), + msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), + }; + app_event_tx_clone.send(AppEvent::CodexEvent(ev)); + + let conversation_clone = conversation.clone(); + tokio::spawn(async move { + while let Some(op) = codex_op_rx.recv().await { + let id = conversation_clone.submit(op).await; + if let Err(e) = id { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + while let Ok(event) = conversation.next_event().await { + app_event_tx_clone.send(AppEvent::CodexEvent(event)); + } + }); + + codex_op_tx +} + +/// Spawn agent loops for an existing conversation (e.g., a forked conversation). +/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent +/// events and accepts Ops for submission. +pub(crate) fn spawn_agent_from_existing( + conversation: std::sync::Arc, + session_configured: codex_core::protocol::SessionConfiguredEvent, + app_event_tx: AppEventSender, +) -> UnboundedSender { + let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); + + let app_event_tx_clone = app_event_tx; + tokio::spawn(async move { + // Forward the captured `SessionConfigured` event so it can be rendered in the UI. + let ev = codex_core::protocol::Event { + id: "".to_string(), + msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), + }; + app_event_tx_clone.send(AppEvent::CodexEvent(ev)); + + let conversation_clone = conversation.clone(); + tokio::spawn(async move { + while let Some(op) = codex_op_rx.recv().await { + let id = conversation_clone.submit(op).await; + if let Err(e) = id { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + while let Ok(event) = conversation.next_event().await { + app_event_tx_clone.send(AppEvent::CodexEvent(event)); + } + }); + + codex_op_tx +} diff --git a/codex-rs/tui2/src/chatwidget/interrupts.rs b/codex-rs/tui2/src/chatwidget/interrupts.rs new file mode 100644 index 00000000000..dc1e683ea55 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/interrupts.rs @@ -0,0 +1,96 @@ +use std::collections::VecDeque; + +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::McpToolCallBeginEvent; +use codex_core::protocol::McpToolCallEndEvent; +use codex_core::protocol::PatchApplyEndEvent; +use codex_protocol::approvals::ElicitationRequestEvent; + +use super::ChatWidget; + +#[derive(Debug)] +pub(crate) enum QueuedInterrupt { + ExecApproval(String, ExecApprovalRequestEvent), + ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), + Elicitation(ElicitationRequestEvent), + ExecBegin(ExecCommandBeginEvent), + ExecEnd(ExecCommandEndEvent), + McpBegin(McpToolCallBeginEvent), + McpEnd(McpToolCallEndEvent), + PatchEnd(PatchApplyEndEvent), +} + +#[derive(Default)] +pub(crate) struct InterruptManager { + queue: VecDeque, +} + +impl InterruptManager { + pub(crate) fn new() -> Self { + Self { + queue: VecDeque::new(), + } + } + + #[inline] + pub(crate) fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + pub(crate) fn push_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) { + self.queue.push_back(QueuedInterrupt::ExecApproval(id, ev)); + } + + pub(crate) fn push_apply_patch_approval( + &mut self, + id: String, + ev: ApplyPatchApprovalRequestEvent, + ) { + self.queue + .push_back(QueuedInterrupt::ApplyPatchApproval(id, ev)); + } + + pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) { + self.queue.push_back(QueuedInterrupt::Elicitation(ev)); + } + + pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { + self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); + } + + pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) { + self.queue.push_back(QueuedInterrupt::ExecEnd(ev)); + } + + pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) { + self.queue.push_back(QueuedInterrupt::McpBegin(ev)); + } + + pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) { + self.queue.push_back(QueuedInterrupt::McpEnd(ev)); + } + + pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) { + self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); + } + + pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) { + while let Some(q) = self.queue.pop_front() { + match q { + QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev), + QueuedInterrupt::ApplyPatchApproval(id, ev) => { + chat.handle_apply_patch_approval_now(id, ev) + } + QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), + QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), + QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), + QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), + QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev), + QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev), + } + } + } +} diff --git a/codex-rs/tui2/src/chatwidget/session_header.rs b/codex-rs/tui2/src/chatwidget/session_header.rs new file mode 100644 index 00000000000..32e31b6682e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/session_header.rs @@ -0,0 +1,16 @@ +pub(crate) struct SessionHeader { + model: String, +} + +impl SessionHeader { + pub(crate) fn new(model: String) -> Self { + Self { model } + } + + /// Updates the header's model text. + pub(crate) fn set_model(&mut self, model: &str) { + if self.model != model { + self.model = model.to_string(); + } + } +} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap new file mode 100644 index 00000000000..26c7f587096 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: lines_to_single_string(&approved_lines) +--- +• Added foo.txt (+1 -0) + 1 +hello diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap new file mode 100644 index 00000000000..c69730b4833 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap @@ -0,0 +1,15 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to run the following command? + + Reason: this is a test reason such as one that would be produced by the model + + $ echo hello world + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap new file mode 100644 index 00000000000..ab469f34b6a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to run the following command? + + $ echo hello world + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap new file mode 100644 index 00000000000..a5bfd136b78 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap @@ -0,0 +1,17 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to make the following edits? + + Reason: The model wants to apply changes + + README.md (+2 -0) + + 1 +hello + 2 +world + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap new file mode 100644 index 00000000000..46ec74d1179 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap new file mode 100644 index 00000000000..5024b90a62d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + Press enter to confirm or esc to go back + diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap new file mode 100644 index 00000000000..8900e83d9af --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap new file mode 100644 index 00000000000..a2afe14dfa0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap new file mode 100644 index 00000000000..1b285fb8108 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap new file mode 100644 index 00000000000..8900e83d9af --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap new file mode 100644 index 00000000000..a2afe14dfa0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap new file mode 100644 index 00000000000..1b285fb8108 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap new file mode 100644 index 00000000000..a447b748bb8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -0,0 +1,17 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• I’m going to search the repo for where “Change Approved” is rendered to update + that view. + +• Explored + └ Search Change Approved + Read diff_render.rs + +• Investigating rendering code (0s • esc to interrupt) + + +› Summarize recent commits + + 100% context left diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap new file mode 100644 index 00000000000..9ab9b033808 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -0,0 +1,18 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• -- Indented code block (4 spaces) + SELECT * + FROM "users" + WHERE "email" LIKE '%@example.com'; + + ```sh + printf 'fenced within fenced\n' + ``` + + { + // comment allowed in jsonc + "path": "C:\\Program Files\\App", + "regex": "^foo.*(bar)?$" + } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap new file mode 100644 index 00000000000..3cc0b593d4e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap @@ -0,0 +1,27 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• Working (0s • esc to interrupt) + ↳ Hello, world! 0 + ↳ Hello, world! 1 + ↳ Hello, world! 2 + ↳ Hello, world! 3 + ↳ Hello, world! 4 + ↳ Hello, world! 5 + ↳ Hello, world! 6 + ↳ Hello, world! 7 + ↳ Hello, world! 8 + ↳ Hello, world! 9 + ↳ Hello, world! 10 + ↳ Hello, world! 11 + ↳ Hello, world! 12 + ↳ Hello, world! 13 + ↳ Hello, world! 14 + ↳ Hello, world! 15 + ↳ Hello, world! 16 + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap new file mode 100644 index 00000000000..3d83bdb0f5a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: combined +--- +• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap new file mode 100644 index 00000000000..6d252a0d3e3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: blob +--- +■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap new file mode 100644 index 00000000000..50c08287731 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: lines_to_single_string(&aborted_long) +--- +✗ You canceled the request to run echo + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap new file mode 100644 index 00000000000..d7e1e2ac3a0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: lines_to_single_string(&aborted_multi) +--- +✗ You canceled the request to run echo line1 ... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap new file mode 100644 index 00000000000..2d3767dffb6 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: lines_to_single_string(&decision) +--- +✔ You approved codex to run echo hello world this time diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap new file mode 100644 index 00000000000..93451be714d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap @@ -0,0 +1,36 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 13 }, + content: [ + " ", + " ", + " Would you like to run the following command? ", + " ", + " Reason: this is a test reason such as one that would be produced by the ", + " model ", + " ", + " $ echo hello world ", + " ", + "› 1. Yes, proceed (y) ", + " 2. No, and tell Codex what to do differently (esc) ", + " ", + " Press enter to confirm or esc to cancel ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap new file mode 100644 index 00000000000..7a20304601e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Exploring + └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap new file mode 100644 index 00000000000..b13ce510e00 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Explored + └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap new file mode 100644 index 00000000000..ab15a80ff37 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Exploring + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap new file mode 100644 index 00000000000..21b41860fc9 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap new file mode 100644 index 00000000000..21b41860fc9 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap new file mode 100644 index 00000000000..a38d4c7fd22 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Explored + └ List ls -la + Read foo.txt, bar.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap new file mode 100644 index 00000000000..52ce03bbea3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + How was this? + +› 1. bug Crash, error message, hang, or broken UI/behavior. + 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. + 3. good result Helpful, correct, high‑quality, or delightful result worth + celebrating. + 4. other Slowness, feature suggestion, UX feedback, or anything else. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap new file mode 100644 index 00000000000..21d031df6c8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Upload logs? + + The following files will be sent: + • codex-logs.log + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap new file mode 100644 index 00000000000..3d83bdb0f5a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: combined +--- +• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap new file mode 100644 index 00000000000..f7a2b3dcb66 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Enable full access? + When Codex runs with full access, it can edit any file on your computer and + run commands with network, without your approval. Exercise caution when + enabling full access. This significantly increases the risk of data loss, + leaks, or unexpected behavior. + +› 1. Yes, continue anyway Apply full access for this session + 2. Yes, and don't ask again Enable full access and remember this choice + 3. Cancel Go back without enabling full access + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap new file mode 100644 index 00000000000..3863f9a8d50 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: exec_blob +--- +• Ran sleep 1 + └ (no output) diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap new file mode 100644 index 00000000000..943fe344402 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: last +--- +■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap new file mode 100644 index 00000000000..31c5e74b0a3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: combined +--- +• Viewed Image + └ example.png diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap new file mode 100644 index 00000000000..cbf5f0fb526 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap @@ -0,0 +1,12 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Reasoning Level for gpt-5.1-codex-max + + 1. Low Fast responses with lighter reasoning + 2. Medium (default) Balances speed and reasoning depth for everyday tasks +› 3. High (current) Maximizes reasoning depth for complex problems + 4. Extra high Extra high reasoning depth for complex problems + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap new file mode 100644 index 00000000000..ed6c6fee19d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap @@ -0,0 +1,15 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Reasoning Level for gpt-5.1-codex-max + + 1. Low Fast responses with lighter reasoning + 2. Medium (default) Balances speed and reasoning depth for everyday + tasks + 3. High Maximizes reasoning depth for complex problems +› 4. Extra high (current) Extra high reasoning depth for complex problems + ⚠ Extra high reasoning effort can quickly consume + Plus plan rate limits. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap new file mode 100644 index 00000000000..3937194a1fe --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Model and Effort + Access legacy models by running codex -m or in your config.toml + +› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast + reasoning. + 2. gpt-5.1-codex Optimized for codex. + 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. + 4. gpt-5.1 Broad world knowledge with strong general reasoning. + + Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap new file mode 100644 index 00000000000..d553957350e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Approaching rate limits + Switch to gpt-5.1-codex-mini for lower credit usage? + +› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper, + faster, but less capable. + 2. Keep current model + 3. Keep current model (never show again) Hide future rate limit reminders + about switching models. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap new file mode 100644 index 00000000000..f761e5730bc --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap @@ -0,0 +1,11 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +"• Analyzing (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap new file mode 100644 index 00000000000..567794cea6c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap @@ -0,0 +1,17 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" Would you like to run the following command? " +" " +" Reason: this is a test reason such as one that would be produced by the model " +" " +" $ echo 'hello world' " +" " +"› 1. Yes, proceed (y) " +" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " +" 3. No, and tell Codex what to do differently (esc) " +" " +" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap new file mode 100644 index 00000000000..3a9f08ab94a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: blob +--- +• You ran ls + └ file1 + file2 diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap new file mode 100644 index 00000000000..e139b510881 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: lines_to_single_string(&approved_lines) +--- +• Added foo.txt (+1 -0) + 1 +hello diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap new file mode 100644 index 00000000000..15511611a10 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + + + Would you like to run the following command? + + Reason: this is a test reason such as one that would be produced by the model + + $ echo hello world + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap new file mode 100644 index 00000000000..2bbe9aefcdf --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to run the following command? + + $ echo hello world + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap new file mode 100644 index 00000000000..ed18675ac39 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to make the following edits? + + Reason: The model wants to apply changes + + README.md (+2 -0) + + 1 +hello + 2 +world + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap new file mode 100644 index 00000000000..6758ec62c57 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap new file mode 100644 index 00000000000..6758ec62c57 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap new file mode 100644 index 00000000000..77738439a17 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -0,0 +1,153 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "lines[start_idx..].join(\"\\n\")" +--- +• I need to check the codex-rs repository to explain why the project's binaries + are large. The user is likely seeking specifics about the setup: are Rust + builds static, what features are enabled, and is debug information included? + It could be due to static linking, included OpenSSL, or how panic handling + is set up. I should look into the Cargo.toml file to confirm features and + profiles without needing to edit any code. Let's get started on this! + +─ Worked for 0s ──────────────────────────────────────────────────────────────── + +• I’m going to scan the workspace and Cargo manifests to see build profiles and + dependencies that impact binary size. Then I’ll summarize the main causes. + +• Explored + └ List ls -la + Read Cargo.toml + +• 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 + │ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; + │ … +1 lines + └ --- ansi-escape/Cargo.toml + [package] + … +243 lines + ] } + tracing = { version + +• 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. + +─ Worked for 0s ──────────────────────────────────────────────────────────────── + +• Here’s what’s driving size in this workspace’s binaries. + + Main Causes + + - Static linking style: Each bin (codex, codex-tui, codex-exec, codex-mcp- + server, etc.) statically links its full dependency graph, so common code isn’t + shared at runtime across executables. + - Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors, + and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui, + and ollama you enable reqwest with json/stream, which still pulls a large + transitive set. + - Image/terminal stack: codex-tui includes image (with jpeg), ratatui, + crossterm, and ratatui-image, which together contribute significant code size + (decoders, pixel ops, terminal backends). + - Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and + starlark (in execpolicy) include sizeable parser/VM tables and runtimes. + - Tokio runtime: Broad tokio features (rt-multi-thread, macros, process, + signal) across many crates inflate code size even if only subsets are used + per bin. + - Panic + backtraces: Default panic = unwind and backtrace support keep + unwinding tables and symbols that add weight. + - Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl- + sys with vendored, compiling OpenSSL into the binary—this adds multiple + megabytes per executable. + + Build-Mode Notes + + - Release settings: You use lto = "fat" and codegen-units = 1 (good for size), + but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single + contributor; if you build in release with that setting, binaries can still + be large. + - Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and + assertions—outputs are much larger than cargo build --release. + + If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt- + level = "z", panic abort, tighter tokio/reqwest features) and estimate impact + per binary. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap new file mode 100644 index 00000000000..1e73a237ebc --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap new file mode 100644 index 00000000000..7a04b0ef196 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap new file mode 100644 index 00000000000..4487d0652e8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap new file mode 100644 index 00000000000..1e73a237ebc --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap new file mode 100644 index 00000000000..7a04b0ef196 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap new file mode 100644 index 00000000000..4487d0652e8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap new file mode 100644 index 00000000000..c3bdf60bd2c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• I’m going to search the repo for where “Change Approved” is rendered to update + that view. + +• Explored + └ Search Change Approved + Read diff_render.rs + +• Investigating rendering code (0s • esc to interrupt) + + +› Summarize recent commits + + 100% context left diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap new file mode 100644 index 00000000000..1ed73b5fa5c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +• -- Indented code block (4 spaces) + SELECT * + FROM "users" + WHERE "email" LIKE '%@example.com'; + + ```sh + printf 'fenced within fenced\n' + ``` + + { + // comment allowed in jsonc + "path": "C:\\Program Files\\App", + "regex": "^foo.*(bar)?$" + } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap new file mode 100644 index 00000000000..6d9aa515b1a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• Working (0s • esc to interrupt) + ↳ Hello, world! 0 + ↳ Hello, world! 1 + ↳ Hello, world! 2 + ↳ Hello, world! 3 + ↳ Hello, world! 4 + ↳ Hello, world! 5 + ↳ Hello, world! 6 + ↳ Hello, world! 7 + ↳ Hello, world! 8 + ↳ Hello, world! 9 + ↳ Hello, world! 10 + ↳ Hello, world! 11 + ↳ Hello, world! 12 + ↳ Hello, world! 13 + ↳ Hello, world! 14 + ↳ Hello, world! 15 + ↳ Hello, world! 16 + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap new file mode 100644 index 00000000000..6062087181d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap new file mode 100644 index 00000000000..e8f08a437ac --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob +--- +■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap new file mode 100644 index 00000000000..f04e1f078a8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 495 +expression: lines_to_single_string(&aborted_long) +--- +✗ You canceled the request to run echo + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap new file mode 100644 index 00000000000..d35cb175972 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: lines_to_single_string(&aborted_multi) +--- +✗ You canceled the request to run echo line1 ... + diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap new file mode 100644 index 00000000000..2f0f1412a1f --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: lines_to_single_string(&decision) +--- +✔ You approved codex to run echo hello world this time diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap new file mode 100644 index 00000000000..1c6a3ef1367 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -0,0 +1,36 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 13 }, + content: [ + " ", + " ", + " Would you like to run the following command? ", + " ", + " Reason: this is a test reason such as one that would be produced by the ", + " model ", + " ", + " $ echo hello world ", + " ", + "› 1. Yes, proceed (y) ", + " 2. No, and tell Codex what to do differently (esc) ", + " ", + " Press enter to confirm or esc to cancel ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap new file mode 100644 index 00000000000..588a9503eb3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob1 +--- +• Exploring + └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap new file mode 100644 index 00000000000..492e8b7708c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob2 +--- +• Explored + └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap new file mode 100644 index 00000000000..2ce41709299 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob3 +--- +• Exploring + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap new file mode 100644 index 00000000000..9e29785f715 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob4 +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap new file mode 100644 index 00000000000..296b00f905d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob5 +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap new file mode 100644 index 00000000000..55fa9791234 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob6 +--- +• Explored + └ List ls -la + Read foo.txt, bar.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap new file mode 100644 index 00000000000..4a98242027e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + How was this? + +› 1. bug Crash, error message, hang, or broken UI/behavior. + 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. + 3. good result Helpful, correct, high‑quality, or delightful result worth + celebrating. + 4. other Slowness, feature suggestion, UX feedback, or anything else. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap new file mode 100644 index 00000000000..cc3d8e37559 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Upload logs? + + The following files will be sent: + • codex-logs.log + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap new file mode 100644 index 00000000000..6062087181d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap new file mode 100644 index 00000000000..71dac5f5902 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Enable full access? + When Codex runs with full access, it can edit any file on your computer and + run commands with network, without your approval. Exercise caution when + enabling full access. This significantly increases the risk of data loss, + leaks, or unexpected behavior. + +› 1. Yes, continue anyway Apply full access for this session + 2. Yes, and don't ask again Enable full access and remember this choice + 3. Cancel Go back without enabling full access + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap new file mode 100644 index 00000000000..59eff20acee --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: exec_blob +--- +• Ran sleep 1 + └ (no output) diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap new file mode 100644 index 00000000000..60715e581e0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: last +--- +■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap new file mode 100644 index 00000000000..cf4c6943fd3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +• Viewed Image + └ example.png diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap new file mode 100644 index 00000000000..b4b89736a96 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Reasoning Level for gpt-5.1-codex-max + + 1. Low Fast responses with lighter reasoning + 2. Medium (default) Balances speed and reasoning depth for everyday tasks +› 3. High (current) Maximizes reasoning depth for complex problems + 4. Extra high Extra high reasoning depth for complex problems + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap new file mode 100644 index 00000000000..c5332ff5907 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 1548 +expression: popup +--- + Select Reasoning Level for gpt-5.1-codex-max + + 1. Low Fast responses with lighter reasoning + 2. Medium (default) Balances speed and reasoning depth for everyday + tasks + 3. High Maximizes reasoning depth for complex problems +› 4. Extra high (current) Extra high reasoning depth for complex problems + ⚠ Extra high reasoning effort can quickly consume + Plus plan rate limits. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap new file mode 100644 index 00000000000..56a209ef73a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Model and Effort + Access legacy models by running codex -m or in your config.toml + +› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast + reasoning. + 2. gpt-5.1-codex Optimized for codex. + 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. + 4. gpt-5.1 Broad world knowledge with strong general reasoning. + + Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap new file mode 100644 index 00000000000..e210d1f0a39 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Approaching rate limits + Switch to gpt-5.1-codex-mini for lower credit usage? + +› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper, + faster, but less capable. + 2. Keep current model + 3. Keep current model (never show again) Hide future rate limit reminders + about switching models. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap new file mode 100644 index 00000000000..9fbebfb500f --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 1577 +expression: terminal.backend() +--- +" " +"• Analyzing (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap new file mode 100644 index 00000000000..5e6e33dece9 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" Would you like to run the following command? " +" " +" Reason: this is a test reason such as one that would be produced by the model " +" " +" $ echo 'hello world' " +" " +"› 1. Yes, proceed (y) " +" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " +" 3. No, and tell Codex what to do differently (esc) " +" " +" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap new file mode 100644 index 00000000000..6a49cb253c4 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + ✨ New version available! Would you like to update? + + Full release notes: https://github.com/openai/codex/releases/latest + + +› 1. Yes, update now + 2. No, not now + 3. Don't remind me + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap new file mode 100644 index 00000000000..c67cd637d7a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob +--- +• You ran ls + └ file1 + file2 diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs new file mode 100644 index 00000000000..d9e242674b0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -0,0 +1,3329 @@ +use super::*; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::test_backend::VT100Backend; +use crate::tui::FrameRequester; +use assert_matches::assert_matches; +use codex_common::approval_presets::builtin_approval_presets; +use codex_core::AuthManager; +use codex_core::CodexAuth; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::ConfigToml; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::protocol::AgentMessageDeltaEvent; +use codex_core::protocol::AgentMessageEvent; +use codex_core::protocol::AgentReasoningDeltaEvent; +use codex_core::protocol::AgentReasoningEvent; +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::CreditsSnapshot; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::ExecPolicyAmendment; +use codex_core::protocol::ExitedReviewModeEvent; +use codex_core::protocol::FileChange; +use codex_core::protocol::Op; +use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::RateLimitWindow; +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::ReviewTarget; +use codex_core::protocol::StreamErrorEvent; +use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::TokenCountEvent; +use codex_core::protocol::TokenUsage; +use codex_core::protocol::TokenUsageInfo; +use codex_core::protocol::UndoCompletedEvent; +use codex_core::protocol::UndoStartedEvent; +use codex_core::protocol::ViewImageToolCallEvent; +use codex_core::protocol::WarningEvent; +use codex_protocol::ConversationId; +use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::plan_tool::PlanItemArg; +use codex_protocol::plan_tool::StepStatus; +use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::CodexErrorInfo; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use insta::assert_snapshot; +use pretty_assertions::assert_eq; +use std::collections::HashSet; +use std::path::PathBuf; +use tempfile::NamedTempFile; +use tempfile::tempdir; +use tokio::sync::mpsc::error::TryRecvError; +use tokio::sync::mpsc::unbounded_channel; + +#[cfg(target_os = "windows")] +fn set_windows_sandbox_enabled(enabled: bool) { + codex_core::set_windows_sandbox_enabled(enabled); +} + +fn test_config() -> Config { + // Use base defaults to avoid depending on host state. + + Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + std::env::temp_dir(), + ) + .expect("config") +} + +fn snapshot(percent: f64) -> RateLimitSnapshot { + RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: percent, + window_minutes: Some(60), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + } +} + +#[test] +fn resumed_initial_messages_render_history() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + let conversation_id = ConversationId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "hello from user".to_string(), + images: None, + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "assistant reply".to_string(), + }), + ]), + skill_load_outcome: None, + rollout_path: rollout_file.path().to_path_buf(), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let cells = drain_insert_history(&mut rx); + let mut merged_lines = Vec::new(); + for lines in cells { + let text = lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.clone()) + .collect::(); + merged_lines.push(text); + } + + let text_blob = merged_lines.join("\n"); + assert!( + text_blob.contains("hello from user"), + "expected replayed user message", + ); + assert!( + text_blob.contains("assistant reply"), + "expected replayed agent message", + ); +} + +/// 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(None); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("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(None); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: None, + }), + }); + + 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(None); + + 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); +} + +/// Exiting review restores the pre-review context window indicator. +#[test] +fn review_restores_context_window_indicator() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + let context_window = 13_000; + let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline. + let review_tokens = 12_030; // ~97% remaining after subtracting baseline. + + chat.handle_codex_event(Event { + id: "token-before".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(make_token_info(pre_review_tokens, context_window)), + rate_limits: None, + }), + }); + assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("feature branch".to_string()), + }), + }); + + chat.handle_codex_event(Event { + id: "token-review".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(make_token_info(review_tokens, context_window)), + rate_limits: None, + }), + }); + assert_eq!(chat.bottom_pane.context_window_percent(), Some(97)); + + chat.handle_codex_event(Event { + id: "review-end".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: None, + }), + }); + let _ = drain_insert_history(&mut rx); + + assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); + assert!(!chat.is_review_mode); +} + +/// Receiving a TokenCount event without usage clears the context indicator. +#[test] +fn token_count_none_resets_context_indicator() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(None); + + let context_window = 13_000; + let pre_compact_tokens = 12_700; + + chat.handle_codex_event(Event { + id: "token-before".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(make_token_info(pre_compact_tokens, context_window)), + rate_limits: None, + }), + }); + assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); + + chat.handle_codex_event(Event { + id: "token-cleared".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: None, + rate_limits: None, + }), + }); + assert_eq!(chat.bottom_pane.context_window_percent(), None); +} + +#[test] +fn context_indicator_shows_used_tokens_when_window_unknown() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")); + + chat.config.model_context_window = None; + let auto_compact_limit = 200_000; + chat.config.model_auto_compact_token_limit = Some(auto_compact_limit); + + // No model window, so the indicator should fall back to showing tokens used. + let total_tokens = 106_000; + let token_usage = TokenUsage { + total_tokens, + ..TokenUsage::default() + }; + let token_info = TokenUsageInfo { + total_token_usage: token_usage.clone(), + last_token_usage: token_usage, + model_context_window: None, + }; + + chat.handle_codex_event(Event { + id: "token-usage".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(token_info), + rate_limits: None, + }), + }); + + assert_eq!(chat.bottom_pane.context_window_percent(), None); + assert_eq!( + chat.bottom_pane.context_window_used_tokens(), + Some(total_tokens) + ); +} + +#[cfg_attr( + target_os = "macos", + ignore = "system configuration APIs are blocked under macOS seatbelt" +)] +#[tokio::test] +async fn helpers_are_available_and_do_not_panic() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let cfg = test_config(); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg); + let conversation_manager = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + cfg.model_provider.clone(), + )); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + let init = ChatWidgetInit { + config: cfg, + frame_requester: FrameRequester::test_dummy(), + app_event_tx: tx, + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: false, + auth_manager, + models_manager: conversation_manager.get_models_manager(), + feedback: codex_feedback::CodexFeedback::new(), + skills: None, + is_first_run: true, + model_family, + }; + let mut w = ChatWidget::new(init, conversation_manager); + // Basic construction sanity. + let _ = &mut w; +} + +// --- Helpers for tests that need direct construction and event draining --- +fn make_chatwidget_manual( + model_override: Option<&str>, +) -> ( + ChatWidget, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, +) { + let (tx_raw, rx) = unbounded_channel::(); + let app_event_tx = AppEventSender::new(tx_raw); + let (op_tx, op_rx) = unbounded_channel::(); + let mut cfg = test_config(); + let resolved_model = model_override + .map(str::to_owned) + .unwrap_or_else(|| ModelsManager::get_model_offline(cfg.model.as_deref())); + if let Some(model) = model_override { + cfg.model = Some(model.to_string()); + } + let bottom = BottomPane::new(BottomPaneParams { + app_event_tx: app_event_tx.clone(), + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: cfg.animations, + skills: None, + }); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + let widget = ChatWidget { + app_event_tx, + codex_op_tx: op_tx, + bottom_pane: bottom, + active_cell: None, + config: cfg.clone(), + model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg), + auth_manager: auth_manager.clone(), + models_manager: Arc::new(ModelsManager::new(auth_manager)), + session_header: SessionHeader::new(resolved_model.clone()), + initial_user_message: None, + token_info: None, + rate_limit_snapshot: None, + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + stream_controller: None, + running_commands: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + task_complete_pending: false, + mcp_startup_status: None, + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, + conversation_id: None, + frame_requester: FrameRequester::test_dummy(), + show_welcome_banner: true, + queued_user_messages: VecDeque::new(), + suppress_session_configured_redraw: false, + pending_notification: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), + feedback: codex_feedback::CodexFeedback::new(), + current_rollout_path: None, + }; + (widget, rx, op_rx) +} + +fn set_chatgpt_auth(chat: &mut ChatWidget) { + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + chat.models_manager = Arc::new(ModelsManager::new(chat.auth_manager.clone())); +} + +pub(crate) fn make_chatwidget_manual_with_sender() -> ( + ChatWidget, + AppEventSender, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, +) { + let (widget, rx, op_rx) = make_chatwidget_manual(None); + let app_event_tx = widget.app_event_tx.clone(); + (widget, app_event_tx, rx, op_rx) +} + +fn drain_insert_history( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Vec>> { + let mut out = Vec::new(); + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev { + let mut lines = cell.display_lines(80); + if !cell.is_stream_continuation() && !out.is_empty() && !lines.is_empty() { + lines.insert(0, "".into()); + } + out.push(lines) + } + } + out +} + +fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { + let mut s = String::new(); + for line in lines { + for span in &line.spans { + s.push_str(&span.content); + } + s.push('\n'); + } + s +} + +fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo { + fn usage(total_tokens: i64) -> TokenUsage { + TokenUsage { + total_tokens, + ..TokenUsage::default() + } + } + + TokenUsageInfo { + total_token_usage: usage(total_tokens), + last_token_usage: usage(total_tokens), + model_context_window: Some(context_window), + } +} + +#[test] +fn rate_limit_warnings_emit_thresholds() { + let mut state = RateLimitWarningState::default(); + let mut warnings: Vec = Vec::new(); + + warnings.extend(state.take_warnings(Some(10.0), Some(10079), Some(55.0), Some(299))); + warnings.extend(state.take_warnings(Some(55.0), Some(10081), Some(10.0), Some(299))); + warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(80.0), Some(299))); + warnings.extend(state.take_warnings(Some(80.0), Some(10081), Some(10.0), Some(299))); + warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(95.0), Some(299))); + warnings.extend(state.take_warnings(Some(95.0), Some(10079), Some(10.0), Some(299))); + + assert_eq!( + warnings, + vec![ + String::from( + "Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown." + ), + String::from( + "Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.", + ), + String::from( + "Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown." + ), + String::from( + "Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.", + ), + ], + "expected one warning per limit for the highest crossed threshold" + ); +} + +#[test] +fn test_rate_limit_warnings_monthly() { + let mut state = RateLimitWarningState::default(); + let mut warnings: Vec = Vec::new(); + + warnings.extend(state.take_warnings(Some(75.0), Some(43199), None, None)); + assert_eq!( + warnings, + vec![String::from( + "Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.", + ),], + "expected one warning per limit for the highest crossed threshold" + ); +} + +#[test] +fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("17.5".to_string()), + }), + plan_type: None, + })); + let initial_balance = chat + .rate_limit_snapshot + .as_ref() + .and_then(|snapshot| snapshot.credits.as_ref()) + .and_then(|credits| credits.balance.as_deref()); + assert_eq!(initial_balance, Some("17.5")); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 80.0, + window_minutes: Some(60), + resets_at: Some(123), + }), + secondary: None, + credits: None, + plan_type: None, + })); + + let display = chat + .rate_limit_snapshot + .as_ref() + .expect("rate limits should be cached"); + let credits = display + .credits + .as_ref() + .expect("credits should persist when headers omit them"); + + assert_eq!(credits.balance.as_deref(), Some("17.5")); + assert!(!credits.unlimited); + assert_eq!( + display.primary.as_ref().map(|window| window.used_percent), + Some(80.0) + ); +} + +#[test] +fn rate_limit_snapshot_updates_and_retains_plan_type() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 10.0, + window_minutes: Some(60), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: 5.0, + window_minutes: Some(300), + resets_at: None, + }), + credits: None, + plan_type: Some(PlanType::Plus), + })); + assert_eq!(chat.plan_type, Some(PlanType::Plus)); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 25.0, + window_minutes: Some(30), + resets_at: Some(123), + }), + secondary: Some(RateLimitWindow { + used_percent: 15.0, + window_minutes: Some(300), + resets_at: Some(234), + }), + credits: None, + plan_type: Some(PlanType::Pro), + })); + assert_eq!(chat.plan_type, Some(PlanType::Pro)); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 30.0, + window_minutes: Some(60), + resets_at: Some(456), + }), + secondary: Some(RateLimitWindow { + used_percent: 18.0, + window_minutes: Some(300), + resets_at: Some(567), + }), + credits: None, + plan_type: None, + })); + assert_eq!(chat.plan_type, Some(PlanType::Pro)); +} + +#[test] +fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { + let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)); + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + + chat.on_rate_limit_snapshot(Some(snapshot(95.0))); + + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); +} + +#[test] +fn rate_limit_switch_prompt_shows_once_per_session() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); + chat.auth_manager = AuthManager::from_auth_for_testing(auth); + + chat.on_rate_limit_snapshot(Some(snapshot(90.0))); + assert!( + chat.rate_limit_warnings.primary_index >= 1, + "warnings not emitted" + ); + chat.maybe_show_pending_rate_limit_prompt(); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + + chat.on_rate_limit_snapshot(Some(snapshot(95.0))); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); +} + +#[test] +fn rate_limit_switch_prompt_respects_hidden_notice() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); + chat.auth_manager = AuthManager::from_auth_for_testing(auth); + chat.config.notices.hide_rate_limit_model_nudge = Some(true); + + chat.on_rate_limit_snapshot(Some(snapshot(95.0))); + + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); +} + +#[test] +fn rate_limit_switch_prompt_defers_until_task_complete() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); + chat.auth_manager = AuthManager::from_auth_for_testing(auth); + + chat.bottom_pane.set_task_running(true); + chat.on_rate_limit_snapshot(Some(snapshot(90.0))); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + + chat.bottom_pane.set_task_running(false); + chat.maybe_show_pending_rate_limit_prompt(); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); +} + +#[test] +fn rate_limit_switch_prompt_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")); + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + + chat.on_rate_limit_snapshot(Some(snapshot(92.0))); + chat.maybe_show_pending_rate_limit_prompt(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("rate_limit_switch_prompt_popup", popup); +} + +// (removed experimental resize snapshot test) + +#[test] +fn exec_approval_emits_proposed_command_and_decision_history() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Trigger an exec approval request with a short, single-line command + let ev = ExecApprovalRequestEvent { + call_id: "call-short".into(), + turn_id: "turn-short".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: Some( + "this is a test reason such as one that would be produced by the model".into(), + ), + proposed_execpolicy_amendment: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-short".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + let proposed_cells = drain_insert_history(&mut rx); + assert!( + proposed_cells.is_empty(), + "expected approval request to render via modal without emitting history cells" + ); + + // The approval modal should display the command snippet for user confirmation. + let area = Rect::new(0, 0, 80, chat.desired_height(80)); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}")); + + // Approve via keyboard and verify a concise decision history line is added + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let decision = drain_insert_history(&mut rx) + .pop() + .expect("expected decision cell in history"); + assert_snapshot!( + "exec_approval_history_decision_approved_short", + lines_to_single_string(&decision) + ); +} + +#[test] +fn exec_approval_decision_truncates_multiline_and_long_commands() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Multiline command: modal should show full command, history records decision only + let ev_multi = ExecApprovalRequestEvent { + call_id: "call-multi".into(), + turn_id: "turn-multi".into(), + command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: Some( + "this is a test reason such as one that would be produced by the model".into(), + ), + proposed_execpolicy_amendment: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-multi".into(), + msg: EventMsg::ExecApprovalRequest(ev_multi), + }); + let proposed_multi = drain_insert_history(&mut rx); + assert!( + proposed_multi.is_empty(), + "expected multiline approval request to render via modal without emitting history cells" + ); + + let area = Rect::new(0, 0, 80, chat.desired_height(80)); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + let mut saw_first_line = false; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("echo line1") { + saw_first_line = true; + break; + } + } + assert!( + saw_first_line, + "expected modal to show first line of multiline snippet" + ); + + // Deny via keyboard; decision snippet should be single-line and elided with " ..." + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + let aborted_multi = drain_insert_history(&mut rx) + .pop() + .expect("expected aborted decision cell (multiline)"); + assert_snapshot!( + "exec_approval_history_decision_aborted_multiline", + lines_to_single_string(&aborted_multi) + ); + + // Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ... + let long = format!("echo {}", "a".repeat(200)); + let ev_long = ExecApprovalRequestEvent { + call_id: "call-long".into(), + turn_id: "turn-long".into(), + command: vec!["bash".into(), "-lc".into(), long], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: None, + proposed_execpolicy_amendment: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-long".into(), + msg: EventMsg::ExecApprovalRequest(ev_long), + }); + let proposed_long = drain_insert_history(&mut rx); + assert!( + proposed_long.is_empty(), + "expected long approval request to avoid emitting history cells before decision" + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + let aborted_long = drain_insert_history(&mut rx) + .pop() + .expect("expected aborted decision cell (long)"); + assert_snapshot!( + "exec_approval_history_decision_aborted_long", + lines_to_single_string(&aborted_long) + ); +} + +// --- Small helpers to tersely drive exec begin/end and snapshot active cell --- +fn begin_exec_with_source( + chat: &mut ChatWidget, + call_id: &str, + raw_cmd: &str, + source: ExecCommandSource, +) -> ExecCommandBeginEvent { + // Build the full command vec and parse it using core's parser, + // then convert to protocol variants for the event payload. + let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; + let parsed_cmd: Vec = codex_core::parse_command::parse_command(&command); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let interaction_input = None; + let event = ExecCommandBeginEvent { + call_id: call_id.to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source, + interaction_input, + }; + chat.handle_codex_event(Event { + id: call_id.to_string(), + msg: EventMsg::ExecCommandBegin(event.clone()), + }); + event +} + +fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) -> ExecCommandBeginEvent { + begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent) +} + +fn end_exec( + chat: &mut ChatWidget, + begin_event: ExecCommandBeginEvent, + stdout: &str, + stderr: &str, + exit_code: i32, +) { + let aggregated = if stderr.is_empty() { + stdout.to_string() + } else { + format!("{stdout}{stderr}") + }; + let ExecCommandBeginEvent { + call_id, + turn_id, + command, + cwd, + parsed_cmd, + source, + interaction_input, + process_id, + } = begin_event; + chat.handle_codex_event(Event { + id: call_id.clone(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id, + process_id, + turn_id, + command, + cwd, + parsed_cmd, + source, + interaction_input, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + aggregated_output: aggregated.clone(), + exit_code, + duration: std::time::Duration::from_millis(5), + formatted_output: aggregated, + }), + }); +} + +fn active_blob(chat: &ChatWidget) -> String { + let lines = chat + .active_cell + .as_ref() + .expect("active cell present") + .display_lines(80); + lines_to_single_string(&lines) +} + +fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { + let models = chat + .models_manager + .try_list_models() + .expect("models lock available"); + models + .iter() + .find(|&preset| preset.model == model) + .cloned() + .unwrap_or_else(|| panic!("{model} preset not found")) +} + +#[test] +fn empty_enter_during_task_does_not_queue() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate running task so submissions would normally be queued. + chat.bottom_pane.set_task_running(true); + + // Press Enter with an empty composer. + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Ensure nothing was queued. + assert!(chat.queued_user_messages.is_empty()); +} + +#[test] +fn alt_up_edits_most_recent_queued_message() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate a running task so messages would normally be queued. + chat.bottom_pane.set_task_running(true); + + // Seed two queued messages. + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + // Press Alt+Up to edit the most recent (last) queued message. + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + + // Composer should now contain the last queued message. + assert_eq!( + chat.bottom_pane.composer_text(), + "second queued".to_string() + ); + // And the queue should now contain only the remaining (older) item. + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "first queued" + ); +} + +/// Pressing Up to recall the most recent history entry and immediately queuing +/// it while a task is running should always enqueue the same text, even when it +/// is queued repeatedly. +#[test] +fn enqueueing_history_prompt_multiple_times_is_stable() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Submit an initial prompt to seed history. + chat.bottom_pane.set_composer_text("repeat me".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Simulate an active task so further submissions are queued. + chat.bottom_pane.set_task_running(true); + + for _ in 0..3 { + // Recall the prompt from history and ensure it is what we expect. + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); + + // Queue the prompt while the task is running. + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + } + + assert_eq!(chat.queued_user_messages.len(), 3); + for message in chat.queued_user_messages.iter() { + assert_eq!(message.text, "repeat me"); + } +} + +#[test] +fn streaming_final_answer_keeps_task_running_state() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + + chat.on_task_started(); + chat.on_agent_message_delta("Final answer line\n".to_string()); + chat.on_commit_tick(); + + assert!(chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_widget().is_none()); + + chat.bottom_pane + .set_composer_text("queued submission".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "queued submission" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } + assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); +} + +#[test] +fn ctrl_c_shutdown_ignores_caps_lock() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); + + match op_rx.try_recv() { + Ok(Op::Shutdown) => {} + other => panic!("expected Op::Shutdown, got {other:?}"), + } +} + +#[test] +fn ctrl_c_cleared_prompt_is_recoverable_via_history() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + + chat.bottom_pane.insert_str("draft message "); + chat.bottom_pane + .attach_image(PathBuf::from("/tmp/preview.png"), 24, 42, "png"); + let placeholder = "[preview.png 24x42]"; + assert!( + chat.bottom_pane.composer_text().ends_with(placeholder), + "expected placeholder {placeholder:?} in composer text" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + let restored_text = chat.bottom_pane.composer_text(); + assert!( + restored_text.ends_with(placeholder), + "expected placeholder {placeholder:?} after history recall" + ); + assert!(restored_text.starts_with("draft message ")); + assert!(!chat.bottom_pane.ctrl_c_quit_hint_visible()); + + let images = chat.bottom_pane.take_recent_submission_images(); + assert!( + images.is_empty(), + "attachments are not preserved in history recall" + ); +} + +#[test] +fn exec_history_cell_shows_working_then_completed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Begin command + let begin = begin_exec(&mut chat, "call-1", "echo done"); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); + + // End command successfully + end_exec(&mut chat, begin, "done", "", 0); + + let cells = drain_insert_history(&mut rx); + // Exec end now finalizes and flushes the exec cell immediately. + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + // Inspect the flushed exec cell rendering. + let lines = &cells[0]; + let blob = lines_to_single_string(lines); + // New behavior: no glyph markers; ensure command is shown and no panic. + assert!( + blob.contains("• Ran"), + "expected summary header present: {blob:?}" + ); + assert!( + blob.contains("echo done"), + "expected command text to be present: {blob:?}" + ); +} + +#[test] +fn exec_history_cell_shows_working_then_failed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Begin command + let begin = begin_exec(&mut chat, "call-2", "false"); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); + + // End command with failure + end_exec(&mut chat, begin, "", "Bloop", 2); + + let cells = drain_insert_history(&mut rx); + // Exec end with failure should also flush immediately. + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let lines = &cells[0]; + let blob = lines_to_single_string(lines); + assert!( + blob.contains("• Ran false"), + "expected command and header text present: {blob:?}" + ); + assert!(blob.to_lowercase().contains("bloop"), "expected error text"); +} + +#[test] +fn exec_end_without_begin_uses_event_command() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo orphaned".to_string(), + ]; + let parsed_cmd = codex_core::parse_command::parse_command(&command); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + chat.handle_codex_event(Event { + id: "call-orphan".to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-orphan".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done".to_string(), + stderr: String::new(), + aggregated_output: "done".to_string(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + formatted_output: "done".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("• Ran echo orphaned"), + "expected command text to come from event: {blob:?}" + ); + assert!( + !blob.contains("call-orphan"), + "call id should not be rendered when event has the command: {blob:?}" + ); +} + +#[test] +fn exec_history_shows_unified_exec_startup_commands() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let begin = begin_exec_with_source( + &mut chat, + "call-startup", + "echo unified exec startup", + ExecCommandSource::UnifiedExecStartup, + ); + assert!( + drain_insert_history(&mut rx).is_empty(), + "exec begin should not flush until completion" + ); + + end_exec(&mut chat, begin, "echo unified exec startup\n", "", 0); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("• Ran echo unified exec startup"), + "expected startup command to render: {blob:?}" + ); +} + +/// 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(None); + + // 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"); +} + +#[test] +fn slash_init_skips_when_project_doc_exists() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + let tempdir = tempdir().unwrap(); + let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); + std::fs::write(&existing_path, "existing instructions").unwrap(); + chat.config.cwd = tempdir.path().to_path_buf(); + + chat.dispatch_command(SlashCommand::Init); + + match op_rx.try_recv() { + Err(TryRecvError::Empty) => {} + other => panic!("expected no Codex op to be sent, got {other:?}"), + } + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains(DEFAULT_PROJECT_DOC_FILENAME), + "info message should mention the existing file: {rendered:?}" + ); + assert!( + rendered.contains("Skipping /init"), + "info message should explain why /init was skipped: {rendered:?}" + ); + assert_eq!( + std::fs::read_to_string(existing_path).unwrap(), + "existing instructions" + ); +} + +#[test] +fn slash_quit_requests_exit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Quit); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest)); +} + +#[test] +fn slash_exit_requests_exit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Exit); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest)); +} + +#[test] +fn slash_resume_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Resume); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); +} + +#[test] +fn slash_undo_sends_op() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Undo); + + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Undo)) => {} + other => panic!("expected AppEvent::CodexOp(Op::Undo), got {other:?}"), + } +} + +#[test] +fn slash_rollout_displays_current_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl"); + chat.current_rollout_path = Some(rollout_path.clone()); + + chat.dispatch_command(SlashCommand::Rollout); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected info message for rollout path"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains(&rollout_path.display().to_string()), + "expected rollout path to be shown: {rendered}" + ); +} + +#[test] +fn slash_rollout_handles_missing_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Rollout); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected info message explaining missing path" + ); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("not available"), + "expected missing rollout path message: {rendered}" + ); +} + +#[test] +fn undo_success_events_render_info_messages() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "turn-1".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { + message: Some("Undo requested for the last turn...".to_string()), + }), + }); + assert!( + chat.bottom_pane.status_indicator_visible(), + "status indicator should be visible during undo" + ); + + chat.handle_codex_event(Event { + id: "turn-1".to_string(), + msg: EventMsg::UndoCompleted(UndoCompletedEvent { + success: true, + message: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected final status only"); + assert!( + !chat.bottom_pane.status_indicator_visible(), + "status indicator should be hidden after successful undo" + ); + + let completed = lines_to_single_string(&cells[0]); + assert!( + completed.contains("Undo completed successfully."), + "expected default success message, got {completed:?}" + ); +} + +#[test] +fn undo_failure_events_render_error_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "turn-2".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + assert!( + chat.bottom_pane.status_indicator_visible(), + "status indicator should be visible during undo" + ); + + chat.handle_codex_event(Event { + id: "turn-2".to_string(), + msg: EventMsg::UndoCompleted(UndoCompletedEvent { + success: false, + message: Some("Failed to restore workspace state.".to_string()), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected final status only"); + assert!( + !chat.bottom_pane.status_indicator_visible(), + "status indicator should be hidden after failed undo" + ); + + let completed = lines_to_single_string(&cells[0]); + assert!( + completed.contains("Failed to restore workspace state."), + "expected failure message, got {completed:?}" + ); +} + +#[test] +fn undo_started_hides_interrupt_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "turn-hint".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be active"); + assert!( + !status.interrupt_hint_visible(), + "undo should hide the interrupt hint because the operation cannot be cancelled" + ); +} + +/// 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(None); + + // 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(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(None); + + 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, + ReviewRequest { + target: ReviewTarget::Custom { + instructions: "please audit dependencies".to_string(), + }, + user_facing_hint: None, + } + ); + } + 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(None); + + 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"); +} + +#[test] +fn view_image_tool_call_adds_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let image_path = chat.config.cwd.join("example.png"); + + chat.handle_codex_event(Event { + id: "sub-image".into(), + msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent { + call_id: "call-image".into(), + path: image_path, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected a single history cell"); + let combined = lines_to_single_string(&cells[0]); + assert_snapshot!("local_image_attachment_history_snapshot", combined); +} + +// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗ +// marker (replacing the spinner) and flushes it into history. +#[test] +fn interrupt_exec_marks_failed_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Begin a long-running command so we have an active exec cell with a spinner. + begin_exec(&mut chat, "call-int", "sleep 1"); + + // Simulate the task being aborted (as if ESC was pressed), which should + // cause the active exec cell to be finalized as failed and flushed. + chat.handle_codex_event(Event { + id: "call-int".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + !cells.is_empty(), + "expected finalized exec cell to be inserted into history" + ); + + // The first inserted cell should be the finalized exec; snapshot its text. + let exec_blob = lines_to_single_string(&cells[0]); + assert_snapshot!("interrupt_exec_marks_failed", exec_blob); +} + +// Snapshot test: after an interrupted turn, a gentle error message is inserted +// suggesting the user to tell the model what to do differently and to use /feedback. +#[test] +fn interrupted_turn_error_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate an in-progress task so the widget is in a running state. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + + // Abort the turn (like pressing Esc) and drain inserted history. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + !cells.is_empty(), + "expected error message to be inserted after interruption" + ); + let last = lines_to_single_string(cells.last().unwrap()); + assert_snapshot!("interrupted_turn_error_message", last); +} + +/// 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, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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)); + 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] +async fn review_branch_picker_escape_navigates_back_then_dismisses() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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)); + 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(area, &mut buf); + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + let s = buf[(x, y)].symbol(); + if s.is_empty() { + row.push(' '); + } else { + row.push_str(s); + } + } + if !row.trim().is_empty() { + return row; + } + } + String::new() +} + +fn render_bottom_popup(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(area, &mut buf); + + let mut lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect(); + + while lines.first().is_some_and(|line| line.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|line| line.trim().is_empty()) { + lines.pop(); + } + + lines.join("\n") +} + +#[test] +fn model_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")); + chat.open_model_popup(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("model_selection_popup", popup); +} + +#[test] +fn approvals_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + chat.config.notices.hide_full_access_warning = None; + chat.open_approvals_popup(); + + let popup = render_bottom_popup(&chat, 80); + #[cfg(target_os = "windows")] + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_snapshot!("approvals_selection_popup", popup); + }); + #[cfg(not(target_os = "windows"))] + assert_snapshot!("approvals_selection_popup", popup); +} + +#[test] +fn preset_matching_ignores_extra_writable_roots() { + let preset = builtin_approval_presets() + .into_iter() + .find(|p| p.id == "auto") + .expect("auto preset exists"); + let current_sandbox = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![PathBuf::from("C:\\extra")], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + assert!( + ChatWidget::preset_matches_current(AskForApproval::OnRequest, ¤t_sandbox, &preset), + "WorkspaceWrite with extra roots should still match the Agent preset" + ); + assert!( + !ChatWidget::preset_matches_current(AskForApproval::Never, ¤t_sandbox, &preset), + "approval mismatch should prevent matching the preset" + ); +} + +#[test] +fn full_access_confirmation_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "full-access") + .expect("full access preset"); + chat.open_full_access_confirmation(preset); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("full_access_confirmation_popup", popup); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + chat.open_windows_sandbox_enable_prompt(preset); + + let popup = render_bottom_popup(&chat, 120); + assert!( + popup.contains("Agent mode on Windows uses an experimental sandbox"), + "expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}" + ); +} + +#[cfg(target_os = "windows")] +#[test] +fn startup_prompts_for_windows_sandbox_when_agent_requested() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + set_windows_sandbox_enabled(false); + chat.config.forced_auto_mode_downgraded_on_windows = true; + + chat.maybe_prompt_windows_sandbox_enable(); + + let popup = render_bottom_popup(&chat, 120); + assert!( + popup.contains("Agent mode on Windows uses an experimental sandbox"), + "expected startup prompt to explain sandbox: {popup}" + ); + assert!( + popup.contains("Enable experimental sandbox"), + "expected startup prompt to offer enabling the sandbox: {popup}" + ); + + set_windows_sandbox_enabled(true); +} + +#[test] +fn model_reasoning_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); + + set_chatgpt_auth(&mut chat); + chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("model_reasoning_selection_popup", popup); +} + +#[test] +fn model_reasoning_selection_popup_extra_high_warning_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); + + set_chatgpt_auth(&mut chat); + chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("model_reasoning_selection_popup_extra_high_warning", popup); +} + +#[test] +fn reasoning_popup_shows_extra_high_with_space() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); + + set_chatgpt_auth(&mut chat); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 120); + assert!( + popup.contains("Extra high"), + "expected popup to include 'Extra high'; popup: {popup}" + ); + assert!( + !popup.contains("Extrahigh"), + "expected popup not to include 'Extrahigh'; popup: {popup}" + ); +} + +#[test] +fn single_reasoning_option_skips_selection() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let single_effort = vec![ReasoningEffortPreset { + effort: ReasoningEffortConfig::High, + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), + }]; + let preset = ModelPreset { + id: "model-with-single-reasoning".to_string(), + model: "model-with-single-reasoning".to_string(), + display_name: "model-with-single-reasoning".to_string(), + description: "".to_string(), + default_reasoning_effort: ReasoningEffortConfig::High, + supported_reasoning_efforts: single_effort, + is_default: false, + upgrade: None, + show_in_picker: true, + }; + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 80); + assert!( + !popup.contains("Select Reasoning Level"), + "expected reasoning selection popup to be skipped" + ); + + let mut events = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } + + assert!( + events + .iter() + .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), + "expected reasoning effort to be applied automatically; events: {events:?}" + ); +} + +#[test] +fn feedback_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Open the feedback category selection popup via slash command. + chat.dispatch_command(SlashCommand::Feedback); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("feedback_selection_popup", popup); +} + +#[test] +fn feedback_upload_consent_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Open the consent popup directly for a chosen category. + chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("feedback_upload_consent_popup", popup); +} + +#[test] +fn reasoning_popup_escape_returns_to_model_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")); + chat.open_model_popup(); + + let preset = get_available_model(&chat, "gpt-5.1-codex"); + chat.open_reasoning_popup(preset); + + let before_escape = render_bottom_popup(&chat, 80); + assert!(before_escape.contains("Select Reasoning Level")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let after_escape = render_bottom_popup(&chat, 80); + assert!(after_escape.contains("Select Model")); + assert!(!after_escape.contains("Select Reasoning Level")); +} + +#[test] +fn exec_history_extends_previous_when_consecutive() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 1) Start "ls -la" (List) + let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la"); + assert_snapshot!("exploring_step1_start_ls", active_blob(&chat)); + + // 2) Finish "ls -la" + end_exec(&mut chat, begin_ls, "", "", 0); + assert_snapshot!("exploring_step2_finish_ls", active_blob(&chat)); + + // 3) Start "cat foo.txt" (Read) + let begin_cat_foo = begin_exec(&mut chat, "call-cat-foo", "cat foo.txt"); + assert_snapshot!("exploring_step3_start_cat_foo", active_blob(&chat)); + + // 4) Complete "cat foo.txt" + end_exec(&mut chat, begin_cat_foo, "hello from foo", "", 0); + assert_snapshot!("exploring_step4_finish_cat_foo", active_blob(&chat)); + + // 5) Start & complete "sed -n 100,200p foo.txt" (treated as Read of foo.txt) + let begin_sed_range = begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt"); + end_exec(&mut chat, begin_sed_range, "chunk", "", 0); + assert_snapshot!("exploring_step5_finish_sed_range", active_blob(&chat)); + + // 6) Start & complete "cat bar.txt" + let begin_cat_bar = begin_exec(&mut chat, "call-cat-bar", "cat bar.txt"); + end_exec(&mut chat, begin_cat_bar, "hello from bar", "", 0); + assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat)); +} + +#[test] +fn user_shell_command_renders_output_not_exploring() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let begin_ls = begin_exec_with_source( + &mut chat, + "user-shell-ls", + "ls", + ExecCommandSource::UserShell, + ); + end_exec(&mut chat, begin_ls, "file1\nfile2\n", "", 0); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected a single history cell for the user command" + ); + let blob = lines_to_single_string(cells.first().unwrap()); + assert_snapshot!("user_shell_ls_output", blob); +} + +#[test] +fn disabled_slash_command_while_task_running_snapshot() { + // Build a chat widget and simulate an active task + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.bottom_pane.set_task_running(true); + + // Dispatch a command that is unavailable while a task runs (e.g., /model) + chat.dispatch_command(SlashCommand::Model); + + // Drain history and snapshot the rendered error line(s) + let cells = drain_insert_history(&mut rx); + assert!( + !cells.is_empty(), + "expected an error message history cell to be emitted", + ); + let blob = lines_to_single_string(cells.last().unwrap()); + assert_snapshot!(blob); +} + +// +// Snapshot test: command approval modal +// +// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal +// and snapshots the visual output using the ratatui TestBackend. +#[test] +fn approval_modal_exec_snapshot() { + // Build a chat widget with manual channels to avoid spawning the agent. + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). + chat.config.approval_policy = AskForApproval::OnRequest; + // Inject an exec approval request to display the approval modal. + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-cmd".into(), + turn_id: "turn-approve-cmd".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: Some( + "this is a test reason such as one that would be produced by the model".into(), + ), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-approve".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + // Render to a fixed-size test terminal and snapshot. + // Call desired_height first and use that exact height for rendering. + let width = 100; + let height = chat.desired_height(width); + let mut terminal = + crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height)) + .expect("create terminal"); + let viewport = Rect::new(0, 0, width, height); + terminal.set_viewport_area(viewport); + + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw approval modal"); + assert!( + terminal + .backend() + .vt100() + .screen() + .contents() + .contains("echo hello world") + ); + assert_snapshot!( + "approval_modal_exec", + terminal.backend().vt100().screen().contents() + ); +} + +// Snapshot test: command approval modal without a reason +// Ensures spacing looks correct when no reason text is provided. +#[test] +fn approval_modal_exec_without_reason_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + chat.config.approval_policy = AskForApproval::OnRequest; + + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-cmd-noreason".into(), + turn_id: "turn-approve-cmd-noreason".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-approve-noreason".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + let width = 100; + let height = chat.desired_height(width); + let mut terminal = + ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw approval modal (no reason)"); + assert_snapshot!( + "approval_modal_exec_no_reason", + terminal.backend().vt100().screen().contents() + ); +} + +// Snapshot test: patch approval modal +#[test] +fn approval_modal_patch_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + chat.config.approval_policy = AskForApproval::OnRequest; + + // Build a small changeset and a reason/grant_root to exercise the prompt text. + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("README.md"), + FileChange::Add { + content: "hello\nworld\n".into(), + }, + ); + let ev = ApplyPatchApprovalRequestEvent { + call_id: "call-approve-patch".into(), + turn_id: "turn-approve-patch".into(), + changes, + reason: Some("The model wants to apply changes".into()), + grant_root: Some(PathBuf::from("/tmp")), + }; + chat.handle_codex_event(Event { + id: "sub-approve-patch".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ev), + }); + + // Render at the widget's desired height and snapshot. + let height = chat.desired_height(80); + let mut terminal = + ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 80, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw patch approval modal"); + assert_snapshot!( + "approval_modal_patch", + terminal.backend().vt100().screen().contents() + ); +} + +#[test] +fn interrupt_restores_queued_messages_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + + // Simulate a running task to enable queuing of user inputs. + chat.bottom_pane.set_task_running(true); + + // Queue two user messages while the task is running. + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + // Composer should now contain the queued messages joined by newlines, in order. + assert_eq!( + chat.bottom_pane.composer_text(), + "first queued\nsecond queued" + ); + + // Queue should be cleared and no new user input should have been auto-submitted. + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt" + ); + + // Drain rx to avoid unused warnings. + let _ = drain_insert_history(&mut rx); +} + +#[test] +fn interrupt_prepends_queued_messages_before_existing_composer_text() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + + chat.bottom_pane.set_task_running(true); + chat.bottom_pane + .set_composer_text("current draft".to_string()); + + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + assert_eq!( + chat.bottom_pane.composer_text(), + "first queued\nsecond queued\ncurrent draft" + ); + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt" + ); + + let _ = drain_insert_history(&mut rx); +} + +// Snapshot test: ChatWidget at very small heights (idle) +// Ensures overall layout behaves when terminal height is extremely constrained. +#[test] +fn ui_snapshots_small_heights_idle() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let (chat, _rx, _op_rx) = make_chatwidget_manual(None); + for h in [1u16, 2, 3] { + let name = format!("chat_small_idle_h{h}"); + let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw chat idle"); + assert_snapshot!(name, terminal.backend()); + } +} + +// Snapshot test: ChatWidget at very small heights (task running) +// Validates how status + composer are presented within tight space. +#[test] +fn ui_snapshots_small_heights_task_running() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Activate status line + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Thinking**".into(), + }), + }); + for h in [1u16, 2, 3] { + let name = format!("chat_small_running_h{h}"); + let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw chat running"); + assert_snapshot!(name, terminal.backend()); + } +} + +// Snapshot test: status widget + approval modal active together +// The modal takes precedence visually; this captures the layout with a running +// task (status indicator active) while an approval request is shown. +#[test] +fn status_widget_and_approval_modal_snapshot() { + use codex_core::protocol::ExecApprovalRequestEvent; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Begin a running task so the status indicator would be active. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + // Provide a deterministic header for the status line. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Analyzing**".into(), + }), + }); + + // Now show an approval modal (e.g. exec approval). + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-exec".into(), + turn_id: "turn-approve-exec".into(), + command: vec!["echo".into(), "hello world".into()], + cwd: PathBuf::from("/tmp"), + reason: Some( + "this is a test reason such as one that would be produced by the model".into(), + ), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello world".into(), + ])), + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-approve-exec".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + // Render at the widget's desired height and snapshot. + let width: u16 = 100; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)) + .expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw status + approval modal"); + assert_snapshot!("status_widget_and_approval_modal", terminal.backend()); +} + +// Snapshot test: status widget active (StatusIndicatorView) +// Ensures the VT100 rendering of the status indicator is stable when active. +#[test] +fn status_widget_active_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Activate the status indicator by simulating a task start. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + // Provide a deterministic header via a bold reasoning chunk. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Analyzing**".into(), + }), + }); + // Render and snapshot. + let height = chat.desired_height(80); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) + .expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw status widget"); + assert_snapshot!("status_widget_active", terminal.backend()); +} + +#[test] +fn background_event_updates_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "bg-1".into(), + msg: EventMsg::BackgroundEvent(BackgroundEventEvent { + message: "Waiting for `vim`".to_string(), + }), + }); + + assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!(chat.current_status_header, "Waiting for `vim`"); + assert!(drain_insert_history(&mut rx).is_empty()); +} + +#[test] +fn apply_patch_events_emit_history_cells() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 1) Approval request -> proposed patch summary cell + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + let ev = ApplyPatchApprovalRequestEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + changes, + reason: None, + grant_root: None, + }; + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ev), + }); + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected approval request to surface via modal without emitting history cells" + ); + + let area = Rect::new(0, 0, 80, chat.desired_height(80)); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + let mut saw_summary = false; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("foo.txt (+1 -0)") { + saw_summary = true; + break; + } + } + assert!(saw_summary, "expected approval modal to show diff summary"); + + // 2) Begin apply -> per-file apply block cell (no global header) + let mut changes2 = HashMap::new(); + changes2.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + let begin = PatchApplyBeginEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + auto_approved: true, + changes: changes2, + }; + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(begin), + }); + let cells = drain_insert_history(&mut rx); + assert!(!cells.is_empty(), "expected apply block cell to be sent"); + let blob = lines_to_single_string(cells.last().unwrap()); + assert!( + blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"), + "expected single-file header with filename (Added/Edited): {blob:?}" + ); + + // 3) End apply success -> success cell + let mut end_changes = HashMap::new(); + end_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + let end = PatchApplyEndEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + stdout: "ok\n".into(), + stderr: String::new(), + success: true, + changes: end_changes, + }; + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyEnd(end), + }); + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "no success cell should be emitted anymore" + ); +} + +#[test] +fn apply_patch_manual_approval_adjusts_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let mut proposed_changes = HashMap::new(); + proposed_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + changes: proposed_changes, + reason: None, + grant_root: None, + }), + }); + drain_insert_history(&mut rx); + + let mut apply_changes = HashMap::new(); + apply_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + auto_approved: false, + changes: apply_changes, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!(!cells.is_empty(), "expected apply block cell to be sent"); + let blob = lines_to_single_string(cells.last().unwrap()); + assert!( + blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"), + "expected apply summary header for foo.txt: {blob:?}" + ); +} + +#[test] +fn apply_patch_manual_flow_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let mut proposed_changes = HashMap::new(); + proposed_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + changes: proposed_changes, + reason: Some("Manual review required".into()), + grant_root: None, + }), + }); + let history_before_apply = drain_insert_history(&mut rx); + assert!( + history_before_apply.is_empty(), + "expected approval modal to defer history emission" + ); + + let mut apply_changes = HashMap::new(); + apply_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + auto_approved: false, + changes: apply_changes, + }), + }); + let approved_lines = drain_insert_history(&mut rx) + .pop() + .expect("approved patch cell"); + + assert_snapshot!( + "apply_patch_manual_flow_history_approved", + lines_to_single_string(&approved_lines) + ); +} + +#[test] +fn apply_patch_approval_sends_op_with_submission_id() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + // Simulate receiving an approval request with a distinct submission id and call id + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("file.rs"), + FileChange::Add { + content: "fn main(){}\n".into(), + }, + ); + let ev = ApplyPatchApprovalRequestEvent { + call_id: "call-999".into(), + turn_id: "turn-999".into(), + changes, + reason: None, + grant_root: None, + }; + chat.handle_codex_event(Event { + id: "sub-123".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ev), + }); + + // Approve via key press 'y' + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + // Expect a CodexOp with PatchApproval carrying the submission id, not call id + let mut found = false; + while let Ok(app_ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev { + assert_eq!(id, "sub-123"); + assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); + found = true; + break; + } + } + assert!(found, "expected PatchApproval op to be sent"); +} + +#[test] +fn apply_patch_full_flow_integration_like() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + + // 1) Backend requests approval + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("pkg.rs"), + FileChange::Add { content: "".into() }, + ); + chat.handle_codex_event(Event { + id: "sub-xyz".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "call-1".into(), + turn_id: "turn-call-1".into(), + changes, + reason: None, + grant_root: None, + }), + }); + + // 2) User approves via 'y' and App receives a CodexOp + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let mut maybe_op: Option = None; + while let Ok(app_ev) = rx.try_recv() { + if let AppEvent::CodexOp(op) = app_ev { + maybe_op = Some(op); + break; + } + } + let op = maybe_op.expect("expected CodexOp after key press"); + + // 3) App forwards to widget.submit_op, which pushes onto codex_op_tx + chat.submit_op(op); + let forwarded = op_rx + .try_recv() + .expect("expected op forwarded to codex channel"); + match forwarded { + Op::PatchApproval { id, decision } => { + assert_eq!(id, "sub-xyz"); + assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); + } + other => panic!("unexpected op forwarded: {other:?}"), + } + + // 4) Simulate patch begin/end events from backend; ensure history cells are emitted + let mut changes2 = HashMap::new(); + changes2.insert( + PathBuf::from("pkg.rs"), + FileChange::Add { content: "".into() }, + ); + chat.handle_codex_event(Event { + id: "sub-xyz".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "call-1".into(), + turn_id: "turn-call-1".into(), + auto_approved: false, + changes: changes2, + }), + }); + let mut end_changes = HashMap::new(); + end_changes.insert( + PathBuf::from("pkg.rs"), + FileChange::Add { content: "".into() }, + ); + chat.handle_codex_event(Event { + id: "sub-xyz".into(), + msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id: "call-1".into(), + turn_id: "turn-call-1".into(), + stdout: String::from("ok"), + stderr: String::new(), + success: true, + changes: end_changes, + }), + }); +} + +#[test] +fn apply_patch_untrusted_shows_approval_modal() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Ensure approval policy is untrusted (OnRequest) + chat.config.approval_policy = AskForApproval::OnRequest; + + // Simulate a patch approval request from backend + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("a.rs"), + FileChange::Add { content: "".into() }, + ); + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "call-1".into(), + turn_id: "turn-call-1".into(), + changes, + reason: None, + grant_root: None, + }), + }); + + // Render and ensure the approval modal title is present + let area = Rect::new(0, 0, 80, 12); + let mut buf = Buffer::empty(area); + chat.render(area, &mut buf); + + let mut contains_title = false; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("Would you like to make the following edits?") { + contains_title = true; + break; + } + } + assert!( + contains_title, + "expected approval modal to be visible with title 'Would you like to make the following edits?'" + ); +} + +#[test] +fn apply_patch_request_shows_diff_summary() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Ensure we are in OnRequest so an approval is surfaced + chat.config.approval_policy = AskForApproval::OnRequest; + + // Simulate backend asking to apply a patch adding two lines to README.md + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("README.md"), + FileChange::Add { + // Two lines (no trailing empty line counted) + content: "line one\nline two\n".into(), + }, + ); + chat.handle_codex_event(Event { + id: "sub-apply".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "call-apply".into(), + turn_id: "turn-apply".into(), + changes, + reason: None, + grant_root: None, + }), + }); + + // No history entries yet; the modal should contain the diff summary + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected approval request to render via modal instead of history" + ); + + let area = Rect::new(0, 0, 80, chat.desired_height(80)); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + + let mut saw_header = false; + let mut saw_line1 = false; + let mut saw_line2 = false; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("README.md (+2 -0)") { + saw_header = true; + } + if row.contains("+line one") { + saw_line1 = true; + } + if row.contains("+line two") { + saw_line2 = true; + } + if saw_header && saw_line1 && saw_line2 { + break; + } + } + assert!(saw_header, "expected modal to show diff header with totals"); + assert!( + saw_line1 && saw_line2, + "expected modal to show per-line diff summary" + ); +} + +#[test] +fn plan_update_renders_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let update = UpdatePlanArgs { + explanation: Some("Adapting plan".to_string()), + plan: vec![ + PlanItemArg { + step: "Explore codebase".into(), + status: StepStatus::Completed, + }, + PlanItemArg { + step: "Implement feature".into(), + status: StepStatus::InProgress, + }, + PlanItemArg { + step: "Write tests".into(), + status: StepStatus::Pending, + }, + ], + }; + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::PlanUpdate(update), + }); + let cells = drain_insert_history(&mut rx); + assert!(!cells.is_empty(), "expected plan update cell to be sent"); + let blob = lines_to_single_string(cells.last().unwrap()); + assert!( + blob.contains("Updated Plan"), + "missing plan header: {blob:?}" + ); + assert!(blob.contains("Explore codebase")); + assert!(blob.contains("Implement feature")); + assert!(blob.contains("Write tests")); +} + +#[test] +fn stream_error_updates_status_indicator() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.bottom_pane.set_task_running(true); + let msg = "Reconnecting... 2/5"; + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: msg.to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected no history cell for StreamError event" + ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), msg); +} + +#[test] +fn warning_event_adds_warning_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::Warning(WarningEvent { + message: "test warning message".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("test warning message"), + "warning cell missing content: {rendered}" + ); +} + +#[test] +fn stream_recovery_restores_previous_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "task".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "retry".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: "Reconnecting... 1/5".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }); + drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "delta".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "hello".to_string(), + }), + }); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Working"); + assert!(chat.retry_status_header.is_none()); +} + +#[test] +fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Begin turn + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + + // First finalized assistant message + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "First message".into(), + }), + }); + + // Second finalized assistant message in the same turn + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Second message".into(), + }), + }); + + // End turn + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::TaskComplete(TaskCompleteEvent { + last_agent_message: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + let combined: String = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect(); + assert!( + combined.contains("First message"), + "missing first message: {combined}" + ); + assert!( + combined.contains("Second message"), + "missing second message: {combined}" + ); + let first_idx = combined.find("First message").unwrap(); + let second_idx = combined.find("Second message").unwrap(); + assert!(first_idx < second_idx, "messages out of order: {combined}"); +} + +#[test] +fn final_reasoning_then_message_without_deltas_are_rendered() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // No deltas; only final reasoning followed by final message. + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoning(AgentReasoningEvent { + text: "I will first analyze the request.".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Here is the result.".into(), + }), + }); + + // Drain history and snapshot the combined visible content. + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_snapshot!(combined); +} + +#[test] +fn deltas_then_same_final_message_are_rendered_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Stream some reasoning deltas first. + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "I will ".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "first analyze the ".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "request.".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoning(AgentReasoningEvent { + text: "request.".into(), + }), + }); + + // Then stream answer deltas, followed by the exact same final message. + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "Here is the ".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "result.".into(), + }), + }); + + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Here is the result.".into(), + }), + }); + + // Snapshot the combined visible content to ensure we render as expected + // when deltas are followed by the identical final message. + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_snapshot!(combined); +} + +// Combined visual snapshot using vt100 for history + direct buffer overlay for UI. +// This renders the final visual as seen in a terminal: history above, then a blank line, +// then the exec block, another blank line, the status line, a blank line, and the composer. +#[test] +fn chatwidget_exec_and_status_layout_vt100_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + 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() }), + }); + + let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()]; + let parsed_cmd = vec![ + ParsedCommand::Search { + query: Some("Change Approved".into()), + path: None, + cmd: "rg \"Change Approved\"".into(), + }, + ParsedCommand::Read { + name: "diff_render.rs".into(), + cmd: "cat diff_render.rs".into(), + path: "diff_render.rs".into(), + }, + ]; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + chat.handle_codex_event(Event { + id: "c1".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "c1".into(), + process_id: None, + turn_id: "turn-1".into(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::Agent, + interaction_input: None, + }), + }); + chat.handle_codex_event(Event { + id: "c1".into(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "c1".into(), + process_id: None, + turn_id: "turn-1".into(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: std::time::Duration::from_millis(16000), + formatted_output: String::new(), + }), + }); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Investigating rendering code**".into(), + }), + }); + chat.bottom_pane + .set_composer_text("Summarize recent commits".to_string()); + + let width: u16 = 80; + let ui_height: u16 = chat.desired_height(width); + let vt_height: u16 = 40; + let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); + + let backend = VT100Backend::new(width, vt_height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(viewport); + + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + } + + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +// E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks +#[test] +fn chatwidget_markdown_code_blocks_vt100_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate a final agent message via streaming deltas instead of a single message + + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + // Build a vt100 visual from the history insertions only (no UI overlay) + let width: u16 = 80; + let height: u16 = 50; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + // Place viewport at the last line so that history lines insert above it + term.set_viewport_area(Rect::new(0, height - 1, width, 1)); + + // Simulate streaming via AgentMessageDelta in 2-character chunks (no final AgentMessage). + let source: &str = r#" + + -- Indented code block (4 spaces) + SELECT * + FROM "users" + WHERE "email" LIKE '%@example.com'; + +````markdown +```sh +printf 'fenced within fenced\n' +``` +```` + +```jsonc +{ + // comment allowed in jsonc + "path": "C:\\Program Files\\App", + "regex": "^foo.*(bar)?$" +} +``` +"#; + + let mut it = source.chars(); + loop { + let mut delta = String::new(); + match it.next() { + Some(c) => delta.push(c), + None => break, + } + if let Some(c2) = it.next() { + delta.push(c2); + } + + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), + }); + // Drive commit ticks and drain emitted history lines into the vt100 buffer. + loop { + chat.on_commit_tick(); + let mut inserted_any = false; + while let Ok(app_ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = app_ev { + let lines = cell.display_lines(width); + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + inserted_any = true; + } + } + if !inserted_any { + break; + } + } + } + + // Finalize the stream without sending a final AgentMessage, to flush any tail. + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskComplete(TaskCompleteEvent { + last_agent_message: None, + }), + }); + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + } + + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +#[test] +fn chatwidget_tall() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + for i in 0..30 { + chat.queue_user_message(format!("Hello, world! {i}").into()); + } + let width: u16 = 80; + let height: u16 = 24; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} diff --git a/codex-rs/tui2/src/cli.rs b/codex-rs/tui2/src/cli.rs new file mode 100644 index 00000000000..b0daa447701 --- /dev/null +++ b/codex-rs/tui2/src/cli.rs @@ -0,0 +1,115 @@ +use clap::Parser; +use clap::ValueHint; +use codex_common::ApprovalModeCliArg; +use codex_common::CliConfigOverrides; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(version)] +pub struct Cli { + /// Optional user prompt to start the session. + #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] + pub prompt: Option, + + /// Optional image(s) to attach to the initial prompt. + #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] + pub images: Vec, + + // Internal controls set by the top-level `codex resume` subcommand. + // These are not exposed as user flags on the base `codex` command. + #[clap(skip)] + pub resume_picker: bool, + + #[clap(skip)] + pub resume_last: bool, + + /// Internal: resume a specific recorded session by id (UUID). Set by the + /// top-level `codex resume ` wrapper; not exposed as a public flag. + #[clap(skip)] + pub resume_session_id: Option, + + /// Internal: show all sessions (disables cwd filtering and shows CWD column). + #[clap(skip)] + pub resume_show_all: bool, + + /// Model the agent should use. + #[arg(long, short = 'm')] + pub model: Option, + + /// Convenience flag to select the local open source model provider. Equivalent to -c + /// model_provider=oss; verifies a local LM Studio or Ollama server is running. + #[arg(long = "oss", default_value_t = false)] + pub oss: bool, + + /// Specify which local provider to use (lmstudio or ollama). + /// If not specified with --oss, will use config default or show selection. + #[arg(long = "local-provider")] + pub oss_provider: Option, + + /// Configuration profile from config.toml to specify default options. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + + /// Select the sandbox policy to use when executing model-generated shell + /// commands. + #[arg(long = "sandbox", short = 's')] + pub sandbox_mode: Option, + + /// Configure when the model requires human approval before executing a command. + #[arg(long = "ask-for-approval", short = 'a')] + pub approval_policy: Option, + + /// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write). + #[arg(long = "full-auto", default_value_t = false)] + pub full_auto: bool, + + /// Skip all confirmation prompts and execute commands without sandboxing. + /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. + #[arg( + long = "dangerously-bypass-approvals-and-sandbox", + alias = "yolo", + default_value_t = false, + conflicts_with_all = ["approval_policy", "full_auto"] + )] + pub dangerously_bypass_approvals_and_sandbox: bool, + + /// Tell the agent to use the specified directory as its working root. + #[clap(long = "cd", short = 'C', value_name = "DIR")] + pub cwd: Option, + + /// Enable web search (off by default). When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). + #[arg(long = "search", default_value_t = false)] + pub web_search: bool, + + /// Additional directories that should be writable alongside the primary workspace. + #[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)] + pub add_dir: Vec, + + #[clap(skip)] + pub config_overrides: CliConfigOverrides, +} + +impl From for Cli { + fn from(cli: codex_tui::Cli) -> Self { + Self { + prompt: cli.prompt, + images: cli.images, + resume_picker: cli.resume_picker, + resume_last: cli.resume_last, + resume_session_id: cli.resume_session_id, + resume_show_all: cli.resume_show_all, + model: cli.model, + oss: cli.oss, + oss_provider: cli.oss_provider, + config_profile: cli.config_profile, + sandbox_mode: cli.sandbox_mode, + approval_policy: cli.approval_policy, + full_auto: cli.full_auto, + dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox, + cwd: cli.cwd, + web_search: cli.web_search, + add_dir: cli.add_dir, + config_overrides: cli.config_overrides, + } + } +} diff --git a/codex-rs/tui2/src/clipboard_paste.rs b/codex-rs/tui2/src/clipboard_paste.rs new file mode 100644 index 00000000000..5863c728b09 --- /dev/null +++ b/codex-rs/tui2/src/clipboard_paste.rs @@ -0,0 +1,504 @@ +use std::path::Path; +use std::path::PathBuf; +use tempfile::Builder; + +#[derive(Debug, Clone)] +pub enum PasteImageError { + ClipboardUnavailable(String), + NoImage(String), + EncodeFailed(String), + IoError(String), +} + +impl std::fmt::Display for PasteImageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PasteImageError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"), + PasteImageError::NoImage(msg) => write!(f, "no image on clipboard: {msg}"), + PasteImageError::EncodeFailed(msg) => write!(f, "could not encode image: {msg}"), + PasteImageError::IoError(msg) => write!(f, "io error: {msg}"), + } + } +} +impl std::error::Error for PasteImageError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncodedImageFormat { + Png, + Jpeg, + Other, +} + +impl EncodedImageFormat { + pub fn label(self) -> &'static str { + match self { + EncodedImageFormat::Png => "PNG", + EncodedImageFormat::Jpeg => "JPEG", + EncodedImageFormat::Other => "IMG", + } + } +} + +#[derive(Debug, Clone)] +pub struct PastedImageInfo { + pub width: u32, + pub height: u32, + pub encoded_format: EncodedImageFormat, // Always PNG for now. +} + +/// Capture image from system clipboard, encode to PNG, and return bytes + info. +#[cfg(not(target_os = "android"))] +pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { + let _span = tracing::debug_span!("paste_image_as_png").entered(); + tracing::debug!("attempting clipboard image read"); + let mut cb = arboard::Clipboard::new() + .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?; + // Sometimes images on the clipboard come as files (e.g. when copy/pasting from + // Finder), sometimes they come as image data (e.g. when pasting from Chrome). + // Accept both, and prefer files if both are present. + let files = cb + .get() + .file_list() + .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string())); + let dyn_img = if let Some(img) = files + .unwrap_or_default() + .into_iter() + .find_map(|f| image::open(f).ok()) + { + tracing::debug!( + "clipboard image opened from file: {}x{}", + img.width(), + img.height() + ); + img + } else { + let _span = tracing::debug_span!("get_image").entered(); + let img = cb + .get_image() + .map_err(|e| PasteImageError::NoImage(e.to_string()))?; + let w = img.width as u32; + let h = img.height as u32; + tracing::debug!("clipboard image opened from image: {}x{}", w, h); + + let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else { + return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into())); + }; + + image::DynamicImage::ImageRgba8(rgba_img) + }; + + let mut png: Vec = Vec::new(); + { + let span = + tracing::debug_span!("encode_image", byte_length = tracing::field::Empty).entered(); + let mut cursor = std::io::Cursor::new(&mut png); + dyn_img + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?; + span.record("byte_length", png.len()); + } + + Ok(( + png, + PastedImageInfo { + width: dyn_img.width(), + height: dyn_img.height(), + encoded_format: EncodedImageFormat::Png, + }, + )) +} + +/// Android/Termux does not support arboard; return a clear error. +#[cfg(target_os = "android")] +pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { + Err(PasteImageError::ClipboardUnavailable( + "clipboard image paste is unsupported on Android".into(), + )) +} + +/// Convenience: write to a temp file and return its path + info. +#[cfg(not(target_os = "android"))] +pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + // First attempt: read image from system clipboard via arboard (native paths or image data). + match paste_image_as_png() { + Ok((png, info)) => { + // Create a unique temporary file with a .png suffix to avoid collisions. + let tmp = Builder::new() + .prefix("codex-clipboard-") + .suffix(".png") + .tempfile() + .map_err(|e| PasteImageError::IoError(e.to_string()))?; + std::fs::write(tmp.path(), &png) + .map_err(|e| PasteImageError::IoError(e.to_string()))?; + // Persist the file (so it remains after the handle is dropped) and return its PathBuf. + let (_file, path) = tmp + .keep() + .map_err(|e| PasteImageError::IoError(e.error.to_string()))?; + Ok((path, info)) + } + Err(e) => { + #[cfg(target_os = "linux")] + { + try_wsl_clipboard_fallback(&e).or(Err(e)) + } + #[cfg(not(target_os = "linux"))] + { + Err(e) + } + } + } +} + +/// Attempt WSL fallback for clipboard image paste. +/// +/// If clipboard is unavailable (common under WSL because arboard cannot access +/// the Windows clipboard), attempt a WSL fallback that calls PowerShell on the +/// Windows side to write the clipboard image to a temporary file, then return +/// the corresponding WSL path. +#[cfg(target_os = "linux")] +fn try_wsl_clipboard_fallback( + error: &PasteImageError, +) -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + use PasteImageError::ClipboardUnavailable; + use PasteImageError::NoImage; + + if !is_probably_wsl() || !matches!(error, ClipboardUnavailable(_) | NoImage(_)) { + return Err(error.clone()); + } + + tracing::debug!("attempting Windows PowerShell clipboard fallback"); + let Some(win_path) = try_dump_windows_clipboard_image() else { + return Err(error.clone()); + }; + + tracing::debug!("powershell produced path: {}", win_path); + let Some(mapped_path) = convert_windows_path_to_wsl(&win_path) else { + return Err(error.clone()); + }; + + let Ok((w, h)) = image::image_dimensions(&mapped_path) else { + return Err(error.clone()); + }; + + // Return the mapped path directly without copying. + // The file will be read and base64-encoded during serialization. + Ok(( + mapped_path, + PastedImageInfo { + width: w, + height: h, + encoded_format: EncodedImageFormat::Png, + }, + )) +} + +/// Try to call a Windows PowerShell command (several common names) to save the +/// clipboard image to a temporary PNG and return the Windows path to that file. +/// Returns None if no command succeeded or no image was present. +#[cfg(target_os = "linux")] +fn try_dump_windows_clipboard_image() -> Option { + // Powershell script: save image from clipboard to a temp png and print the path. + // Force UTF-8 output to avoid encoding issues between powershell.exe (UTF-16LE default) + // and pwsh (UTF-8 default). + let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $img = Get-Clipboard -Format Image; if ($img -ne $null) { $p=[System.IO.Path]::GetTempFileName(); $p = [System.IO.Path]::ChangeExtension($p,'png'); $img.Save($p,[System.Drawing.Imaging.ImageFormat]::Png); Write-Output $p } else { exit 1 }"#; + + for cmd in ["powershell.exe", "pwsh", "powershell"] { + match std::process::Command::new(cmd) + .args(["-NoProfile", "-Command", script]) + .output() + { + // Executing PowerShell command + Ok(output) => { + if output.status.success() { + // Decode as UTF-8 (forced by the script above). + let win_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !win_path.is_empty() { + tracing::debug!("{} saved clipboard image to {}", cmd, win_path); + return Some(win_path); + } + } else { + tracing::debug!("{} returned non-zero status", cmd); + } + } + Err(err) => { + tracing::debug!("{} not executable: {}", cmd, err); + } + } + } + None +} + +#[cfg(target_os = "android")] +pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + // Keep error consistent with paste_image_as_png. + Err(PasteImageError::ClipboardUnavailable( + "clipboard image paste is unsupported on Android".into(), + )) +} + +/// Normalize pasted text that may represent a filesystem path. +/// +/// Supports: +/// - `file://` URLs (converted to local paths) +/// - Windows/UNC paths +/// - shell-escaped single paths (via `shlex`) +pub fn normalize_pasted_path(pasted: &str) -> Option { + let pasted = pasted.trim(); + + // file:// URL → filesystem path + if let Ok(url) = url::Url::parse(pasted) + && url.scheme() == "file" + { + return url.to_file_path().ok(); + } + + // TODO: We'll improve the implementation/unit tests over time, as appropriate. + // Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e + // + // Detect unquoted Windows paths and bypass POSIX shlex which + // treats backslashes as escapes (e.g., C:\Users\Alice\file.png). + // Also handles UNC paths (\\server\share\path). + let looks_like_windows_path = { + // Drive letter path: C:\ or C:/ + let drive = pasted + .chars() + .next() + .map(|c| c.is_ascii_alphabetic()) + .unwrap_or(false) + && pasted.get(1..2) == Some(":") + && pasted + .get(2..3) + .map(|s| s == "\\" || s == "/") + .unwrap_or(false); + // UNC path: \\server\share + let unc = pasted.starts_with("\\\\"); + drive || unc + }; + if looks_like_windows_path { + #[cfg(target_os = "linux")] + { + if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(pasted) + { + return Some(converted); + } + } + return Some(PathBuf::from(pasted)); + } + + // shell-escaped single path → unescaped + let parts: Vec = shlex::Shlex::new(pasted).collect(); + if parts.len() == 1 { + return parts.into_iter().next().map(PathBuf::from); + } + + None +} + +#[cfg(target_os = "linux")] +pub(crate) fn is_probably_wsl() -> bool { + // Primary: Check /proc/version for "microsoft" or "WSL" (most reliable for standard WSL). + if let Ok(version) = std::fs::read_to_string("/proc/version") { + let version_lower = version.to_lowercase(); + if version_lower.contains("microsoft") || version_lower.contains("wsl") { + return true; + } + } + + // Fallback: Check WSL environment variables. This handles edge cases like + // custom Linux kernels installed in WSL where /proc/version may not contain + // "microsoft" or "WSL". + std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() +} + +#[cfg(target_os = "linux")] +fn convert_windows_path_to_wsl(input: &str) -> Option { + if input.starts_with("\\\\") { + return None; + } + + let drive_letter = input.chars().next()?.to_ascii_lowercase(); + if !drive_letter.is_ascii_lowercase() { + return None; + } + + if input.get(1..2) != Some(":") { + return None; + } + + let mut result = PathBuf::from(format!("/mnt/{drive_letter}")); + for component in input + .get(2..)? + .trim_start_matches(['\\', '/']) + .split(['\\', '/']) + .filter(|component| !component.is_empty()) + { + result.push(component); + } + + Some(result) +} + +/// Infer an image format for the provided path based on its extension. +pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { + match path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("png") => EncodedImageFormat::Png, + Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg, + _ => EncodedImageFormat::Other, + } +} + +#[cfg(test)] +mod pasted_paths_tests { + use super::*; + + #[cfg(not(windows))] + #[test] + fn normalize_file_url() { + let input = "file:///tmp/example.png"; + let result = normalize_pasted_path(input).expect("should parse file URL"); + assert_eq!(result, PathBuf::from("/tmp/example.png")); + } + + #[test] + fn normalize_file_url_windows() { + let input = r"C:\Temp\example.png"; + let result = normalize_pasted_path(input).expect("should parse file URL"); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(input) + { + converted + } else { + PathBuf::from(r"C:\Temp\example.png") + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(r"C:\Temp\example.png"); + assert_eq!(result, expected); + } + + #[test] + fn normalize_shell_escaped_single_path() { + let input = "/home/user/My\\ File.png"; + let result = normalize_pasted_path(input).expect("should unescape shell-escaped path"); + assert_eq!(result, PathBuf::from("/home/user/My File.png")); + } + + #[test] + fn normalize_simple_quoted_path_fallback() { + let input = "\"/home/user/My File.png\""; + let result = normalize_pasted_path(input).expect("should trim simple quotes"); + assert_eq!(result, PathBuf::from("/home/user/My File.png")); + } + + #[test] + fn normalize_single_quoted_unix_path() { + let input = "'/home/user/My File.png'"; + let result = normalize_pasted_path(input).expect("should trim single quotes via shlex"); + assert_eq!(result, PathBuf::from("/home/user/My File.png")); + } + + #[test] + fn normalize_multiple_tokens_returns_none() { + // Two tokens after shell splitting → not a single path + let input = "/home/user/a\\ b.png /home/user/c.png"; + let result = normalize_pasted_path(input); + assert!(result.is_none()); + } + + #[test] + fn pasted_image_format_png_jpeg_unknown() { + assert_eq!( + pasted_image_format(Path::new("/a/b/c.PNG")), + EncodedImageFormat::Png + ); + assert_eq!( + pasted_image_format(Path::new("/a/b/c.jpg")), + EncodedImageFormat::Jpeg + ); + assert_eq!( + pasted_image_format(Path::new("/a/b/c.JPEG")), + EncodedImageFormat::Jpeg + ); + assert_eq!( + pasted_image_format(Path::new("/a/b/c")), + EncodedImageFormat::Other + ); + assert_eq!( + pasted_image_format(Path::new("/a/b/c.webp")), + EncodedImageFormat::Other + ); + } + + #[test] + fn normalize_single_quoted_windows_path() { + let input = r"'C:\\Users\\Alice\\My File.jpeg'"; + let result = + normalize_pasted_path(input).expect("should trim single quotes on windows path"); + assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg")); + } + + #[test] + fn normalize_unquoted_windows_path_with_spaces() { + let input = r"C:\\Users\\Alice\\My Pictures\\example image.png"; + let result = normalize_pasted_path(input).expect("should accept unquoted windows path"); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(input) + { + converted + } else { + PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png") + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png"); + assert_eq!(result, expected); + } + + #[test] + fn normalize_unc_windows_path() { + let input = r"\\\\server\\share\\folder\\file.jpg"; + let result = normalize_pasted_path(input).expect("should accept UNC windows path"); + assert_eq!( + result, + PathBuf::from(r"\\\\server\\share\\folder\\file.jpg") + ); + } + + #[test] + fn pasted_image_format_with_windows_style_paths() { + assert_eq!( + pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")), + EncodedImageFormat::Png + ); + assert_eq!( + pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")), + EncodedImageFormat::Jpeg + ); + assert_eq!( + pasted_image_format(Path::new(r"C:\\a\\b\\noext")), + EncodedImageFormat::Other + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn normalize_windows_path_in_wsl() { + // This test only runs on actual WSL systems + if !is_probably_wsl() { + // Skip test if not on WSL + return; + } + let input = r"C:\\Users\\Alice\\Pictures\\example image.png"; + let result = normalize_pasted_path(input).expect("should convert windows path on wsl"); + assert_eq!( + result, + PathBuf::from("/mnt/c/Users/Alice/Pictures/example image.png") + ); + } +} diff --git a/codex-rs/tui2/src/color.rs b/codex-rs/tui2/src/color.rs new file mode 100644 index 00000000000..f5121a1f6c6 --- /dev/null +++ b/codex-rs/tui2/src/color.rs @@ -0,0 +1,75 @@ +pub(crate) fn is_light(bg: (u8, u8, u8)) -> bool { + let (r, g, b) = bg; + let y = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; + y > 128.0 +} + +pub(crate) fn blend(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) { + let r = (fg.0 as f32 * alpha + bg.0 as f32 * (1.0 - alpha)) as u8; + let g = (fg.1 as f32 * alpha + bg.1 as f32 * (1.0 - alpha)) as u8; + let b = (fg.2 as f32 * alpha + bg.2 as f32 * (1.0 - alpha)) as u8; + (r, g, b) +} + +/// Returns the perceptual color distance between two RGB colors. +/// Uses the CIE76 formula (Euclidean distance in Lab space approximation). +pub(crate) fn perceptual_distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 { + // Convert sRGB to linear RGB + fn srgb_to_linear(c: u8) -> f32 { + let c = c as f32 / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + + // Convert RGB to XYZ + fn rgb_to_xyz(r: u8, g: u8, b: u8) -> (f32, f32, f32) { + let r = srgb_to_linear(r); + let g = srgb_to_linear(g); + let b = srgb_to_linear(b); + + let x = r * 0.4124 + g * 0.3576 + b * 0.1805; + let y = r * 0.2126 + g * 0.7152 + b * 0.0722; + let z = r * 0.0193 + g * 0.1192 + b * 0.9505; + (x, y, z) + } + + // Convert XYZ to Lab + fn xyz_to_lab(x: f32, y: f32, z: f32) -> (f32, f32, f32) { + // D65 reference white + let xr = x / 0.95047; + let yr = y / 1.00000; + let zr = z / 1.08883; + + fn f(t: f32) -> f32 { + if t > 0.008856 { + t.powf(1.0 / 3.0) + } else { + 7.787 * t + 16.0 / 116.0 + } + } + + let fx = f(xr); + let fy = f(yr); + let fz = f(zr); + + let l = 116.0 * fy - 16.0; + let a = 500.0 * (fx - fy); + let b = 200.0 * (fy - fz); + (l, a, b) + } + + let (x1, y1, z1) = rgb_to_xyz(a.0, a.1, a.2); + let (x2, y2, z2) = rgb_to_xyz(b.0, b.1, b.2); + + let (l1, a1, b1) = xyz_to_lab(x1, y1, z1); + let (l2, a2, b2) = xyz_to_lab(x2, y2, z2); + + let dl = l1 - l2; + let da = a1 - a2; + let db = b1 - b2; + + (dl * dl + da * da + db * db).sqrt() +} diff --git a/codex-rs/tui2/src/custom_terminal.rs b/codex-rs/tui2/src/custom_terminal.rs new file mode 100644 index 00000000000..46d16a83f05 --- /dev/null +++ b/codex-rs/tui2/src/custom_terminal.rs @@ -0,0 +1,645 @@ +// This is derived from `ratatui::Terminal`, which is licensed under the following terms: +// +// The MIT License (MIT) +// Copyright (c) 2016-2022 Florian Dehau +// Copyright (c) 2023-2025 The Ratatui Developers +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +use std::io; +use std::io::Write; + +use crossterm::cursor::MoveTo; +use crossterm::queue; +use crossterm::style::Colors; +use crossterm::style::Print; +use crossterm::style::SetAttribute; +use crossterm::style::SetBackgroundColor; +use crossterm::style::SetColors; +use crossterm::style::SetForegroundColor; +use crossterm::terminal::Clear; +use derive_more::IsVariant; +use ratatui::backend::Backend; +use ratatui::backend::ClearType; +use ratatui::buffer::Buffer; +use ratatui::layout::Position; +use ratatui::layout::Rect; +use ratatui::layout::Size; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::widgets::WidgetRef; + +#[derive(Debug, Hash)] +pub struct Frame<'a> { + /// Where should the cursor be after drawing this frame? + /// + /// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x, + /// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`. + pub(crate) cursor_position: Option, + + /// The area of the viewport + pub(crate) viewport_area: Rect, + + /// The buffer that is used to draw the current frame + pub(crate) buffer: &'a mut Buffer, +} + +impl Frame<'_> { + /// The area of the current frame + /// + /// This is guaranteed not to change during rendering, so may be called multiple times. + /// + /// If your app listens for a resize event from the backend, it should ignore the values from + /// the event for any calculations that are used to render the current frame and use this value + /// instead as this is the area of the buffer that is used to render the current frame. + pub const fn area(&self) -> Rect { + self.viewport_area + } + + /// Render a [`WidgetRef`] to the current buffer using [`WidgetRef::render_ref`]. + /// + /// Usually the area argument is the size of the current frame or a sub-area of the current + /// frame (which can be obtained using [`Layout`] to split the total area). + #[allow(clippy::needless_pass_by_value)] + pub fn render_widget_ref(&mut self, widget: W, area: Rect) { + widget.render_ref(area, self.buffer); + } + + /// After drawing this frame, make the cursor visible and put it at the specified (x, y) + /// coordinates. If this method is not called, the cursor will be hidden. + /// + /// Note that this will interfere with calls to [`Terminal::hide_cursor`], + /// [`Terminal::show_cursor`], and [`Terminal::set_cursor_position`]. Pick one of the APIs and + /// stick with it. + /// + /// [`Terminal::hide_cursor`]: crate::Terminal::hide_cursor + /// [`Terminal::show_cursor`]: crate::Terminal::show_cursor + /// [`Terminal::set_cursor_position`]: crate::Terminal::set_cursor_position + pub fn set_cursor_position>(&mut self, position: P) { + self.cursor_position = Some(position.into()); + } + + /// Gets the buffer that this `Frame` draws into as a mutable reference. + pub fn buffer_mut(&mut self) -> &mut Buffer { + self.buffer + } +} + +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct Terminal +where + B: Backend + Write, +{ + /// The backend used to interface with the terminal + backend: B, + /// Holds the results of the current and previous draw calls. The two are compared at the end + /// of each draw pass to output the necessary updates to the terminal + buffers: [Buffer; 2], + /// Index of the current buffer in the previous array + current: usize, + /// Whether the cursor is currently hidden + pub hidden_cursor: bool, + /// Area of the viewport + pub viewport_area: Rect, + /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. + pub last_known_screen_size: Size, + /// Last known position of the cursor. Used to find the new area when the viewport is inlined + /// and the terminal resized. + pub last_known_cursor_pos: Position, +} + +impl Drop for Terminal +where + B: Backend, + B: Write, +{ + #[allow(clippy::print_stderr)] + fn drop(&mut self) { + // Attempt to restore the cursor state + if self.hidden_cursor + && let Err(err) = self.show_cursor() + { + eprintln!("Failed to show the cursor: {err}"); + } + } +} + +impl Terminal +where + B: Backend, + B: Write, +{ + /// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`]. + pub fn with_options(mut backend: B) -> io::Result { + let screen_size = backend.size()?; + let cursor_pos = backend.get_cursor_position()?; + Ok(Self { + backend, + buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)], + current: 0, + hidden_cursor: false, + viewport_area: Rect::new(0, cursor_pos.y, 0, 0), + last_known_screen_size: screen_size, + last_known_cursor_pos: cursor_pos, + }) + } + + /// Get a Frame object which provides a consistent view into the terminal state for rendering. + pub fn get_frame(&mut self) -> Frame<'_> { + Frame { + cursor_position: None, + viewport_area: self.viewport_area, + buffer: self.current_buffer_mut(), + } + } + + /// Gets the current buffer as a reference. + fn current_buffer(&self) -> &Buffer { + &self.buffers[self.current] + } + + /// Gets the current buffer as a mutable reference. + fn current_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[self.current] + } + + /// Gets the previous buffer as a reference. + fn previous_buffer(&self) -> &Buffer { + &self.buffers[1 - self.current] + } + + /// Gets the previous buffer as a mutable reference. + fn previous_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[1 - self.current] + } + + /// Gets the backend + pub const fn backend(&self) -> &B { + &self.backend + } + + /// Gets the backend as a mutable reference + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } + + /// Obtains a difference between the previous and the current buffer and passes it to the + /// current backend for drawing. + pub fn flush(&mut self) -> io::Result<()> { + let updates = diff_buffers(self.previous_buffer(), self.current_buffer()); + let last_put_command = updates.iter().rfind(|command| command.is_put()); + if let Some(&DrawCommand::Put { x, y, .. }) = last_put_command { + self.last_known_cursor_pos = Position { x, y }; + } + draw(&mut self.backend, updates.into_iter()) + } + + /// Updates the Terminal so that internal buffers match the requested area. + /// + /// Requested area will be saved to remain consistent when rendering. This leads to a full clear + /// of the screen. + pub fn resize(&mut self, screen_size: Size) -> io::Result<()> { + self.last_known_screen_size = screen_size; + Ok(()) + } + + /// Sets the viewport area. + pub fn set_viewport_area(&mut self, area: Rect) { + self.current_buffer_mut().resize(area); + self.previous_buffer_mut().resize(area); + self.viewport_area = area; + } + + /// Queries the backend for size and resizes if it doesn't match the previous size. + pub fn autoresize(&mut self) -> io::Result<()> { + let screen_size = self.size()?; + if screen_size != self.last_known_screen_size { + self.resize(screen_size)?; + } + Ok(()) + } + + /// Draws a single frame to the terminal. + /// + /// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`]. + /// + /// If the render callback passed to this method can fail, use [`try_draw`] instead. + /// + /// Applications should call `draw` or [`try_draw`] in a loop to continuously render the + /// terminal. These methods are the main entry points for drawing to the terminal. + /// + /// [`try_draw`]: Terminal::try_draw + /// + /// This method will: + /// + /// - autoresize the terminal if necessary + /// - call the render callback, passing it a [`Frame`] reference to render to + /// - flush the current internal state by copying the current buffer to the backend + /// - move the cursor to the last known position if it was set during the rendering closure + /// + /// The render callback should fully render the entire frame when called, including areas that + /// are unchanged from the previous frame. This is because each frame is compared to the + /// previous frame to determine what has changed, and only the changes are written to the + /// terminal. If the render callback does not fully render the frame, the terminal will not be + /// in a consistent state. + pub fn draw(&mut self, render_callback: F) -> io::Result<()> + where + F: FnOnce(&mut Frame), + { + self.try_draw(|frame| { + render_callback(frame); + io::Result::Ok(()) + }) + } + + /// Tries to draw a single frame to the terminal. + /// + /// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise + /// [`Result::Err`] containing the [`std::io::Error`] that caused the failure. + /// + /// This is the equivalent of [`Terminal::draw`] but the render callback is a function or + /// closure that returns a `Result` instead of nothing. + /// + /// Applications should call `try_draw` or [`draw`] in a loop to continuously render the + /// terminal. These methods are the main entry points for drawing to the terminal. + /// + /// [`draw`]: Terminal::draw + /// + /// This method will: + /// + /// - autoresize the terminal if necessary + /// - call the render callback, passing it a [`Frame`] reference to render to + /// - flush the current internal state by copying the current buffer to the backend + /// - move the cursor to the last known position if it was set during the rendering closure + /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal + /// + /// The render callback passed to `try_draw` can return any [`Result`] with an error type that + /// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible + /// to use the `?` operator to propagate errors that occur during rendering. If the render + /// callback returns an error, the error will be returned from `try_draw` as an + /// [`std::io::Error`] and the terminal will not be updated. + /// + /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing + /// purposes, but it is often not used in regular applicationss. + /// + /// The render callback should fully render the entire frame when called, including areas that + /// are unchanged from the previous frame. This is because each frame is compared to the + /// previous frame to determine what has changed, and only the changes are written to the + /// terminal. If the render function does not fully render the frame, the terminal will not be + /// in a consistent state. + pub fn try_draw(&mut self, render_callback: F) -> io::Result<()> + where + F: FnOnce(&mut Frame) -> Result<(), E>, + E: Into, + { + // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets + // and the terminal (if growing), which may OOB. + self.autoresize()?; + + let mut frame = self.get_frame(); + + render_callback(&mut frame).map_err(Into::into)?; + + // We can't change the cursor position right away because we have to flush the frame to + // stdout first. But we also can't keep the frame around, since it holds a &mut to + // Buffer. Thus, we're taking the important data out of the Frame and dropping it. + let cursor_position = frame.cursor_position; + + // Draw to stdout + self.flush()?; + + match cursor_position { + None => self.hide_cursor()?, + Some(position) => { + self.show_cursor()?; + self.set_cursor_position(position)?; + } + } + + self.swap_buffers(); + + Backend::flush(&mut self.backend)?; + + Ok(()) + } + + /// Hides the cursor. + pub fn hide_cursor(&mut self) -> io::Result<()> { + self.backend.hide_cursor()?; + self.hidden_cursor = true; + Ok(()) + } + + /// Shows the cursor. + pub fn show_cursor(&mut self) -> io::Result<()> { + self.backend.show_cursor()?; + self.hidden_cursor = false; + Ok(()) + } + + /// Gets the current cursor position. + /// + /// This is the position of the cursor after the last draw call. + #[allow(dead_code)] + pub fn get_cursor_position(&mut self) -> io::Result { + self.backend.get_cursor_position() + } + + /// Sets the cursor position. + pub fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + let position = position.into(); + self.backend.set_cursor_position(position)?; + self.last_known_cursor_pos = position; + Ok(()) + } + + /// Clear the terminal and force a full redraw on the next draw call. + pub fn clear(&mut self) -> io::Result<()> { + if self.viewport_area.is_empty() { + return Ok(()); + } + self.backend + .set_cursor_position(self.viewport_area.as_position())?; + self.backend.clear_region(ClearType::AfterCursor)?; + // Reset the back buffer to make sure the next update will redraw everything. + self.previous_buffer_mut().reset(); + Ok(()) + } + + /// Clears the inactive buffer and swaps it with the current buffer + pub fn swap_buffers(&mut self) { + self.previous_buffer_mut().reset(); + self.current = 1 - self.current; + } + + /// Queries the real size of the backend. + pub fn size(&self) -> io::Result { + self.backend.size() + } +} + +use ratatui::buffer::Cell; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, IsVariant)] +enum DrawCommand { + Put { x: u16, y: u16, cell: Cell }, + ClearToEnd { x: u16, y: u16, bg: Color }, +} + +fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { + let previous_buffer = &a.content; + let next_buffer = &b.content; + + let mut updates = vec![]; + let mut last_nonblank_columns = vec![0; a.area.height as usize]; + for y in 0..a.area.height { + let row_start = y as usize * a.area.width as usize; + let row_end = row_start + a.area.width as usize; + let row = &next_buffer[row_start..row_end]; + let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset); + + // Scan the row to find the rightmost column that still matters: any non-space glyph, + // any cell whose bg differs from the row’s trailing bg, or any cell with modifiers. + // Multi-width glyphs extend that region through their full displayed width. + // After that point the rest of the row can be cleared with a single ClearToEnd, a perf win + // versus emitting multiple space Put commands. + let mut last_nonblank_column = 0usize; + let mut column = 0usize; + while column < row.len() { + let cell = &row[column]; + let width = cell.symbol().width(); + if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { + last_nonblank_column = column + (width.saturating_sub(1)); + } + column += width.max(1); // treat zero-width symbols as width 1 + } + + if last_nonblank_column + 1 < row.len() { + let (x, y) = a.pos_of(row_start + last_nonblank_column + 1); + updates.push(DrawCommand::ClearToEnd { x, y, bg }); + } + + last_nonblank_columns[y as usize] = last_nonblank_column as u16; + } + + // Cells invalidated by drawing/replacing preceding multi-width characters: + let mut invalidated: usize = 0; + // Cells from the current buffer to skip due to preceding multi-width characters taking + // their place (the skipped cells should be blank anyway), or due to per-cell-skipping: + let mut to_skip: usize = 0; + for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { + if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 { + let (x, y) = a.pos_of(i); + let row = i / a.area.width as usize; + if x <= last_nonblank_columns[row] { + updates.push(DrawCommand::Put { + x, + y, + cell: next_buffer[i].clone(), + }); + } + } + + to_skip = current.symbol().width().saturating_sub(1); + + let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width()); + invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); + } + updates +} + +fn draw(writer: &mut impl Write, commands: I) -> io::Result<()> +where + I: Iterator, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + let mut last_pos: Option = None; + for command in commands { + let (x, y) = match command { + DrawCommand::Put { x, y, .. } => (x, y), + DrawCommand::ClearToEnd { x, y, .. } => (x, y), + }; + // Move the cursor if the previous location was not (x - 1, y) + if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) { + queue!(writer, MoveTo(x, y))?; + } + last_pos = Some(Position { x, y }); + match command { + DrawCommand::Put { cell, .. } => { + if cell.modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: cell.modifier, + }; + diff.queue(writer)?; + modifier = cell.modifier; + } + if cell.fg != fg || cell.bg != bg { + queue!( + writer, + SetColors(Colors::new(cell.fg.into(), cell.bg.into())) + )?; + fg = cell.fg; + bg = cell.bg; + } + + queue!(writer, Print(cell.symbol()))?; + } + DrawCommand::ClearToEnd { bg: clear_bg, .. } => { + queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?; + modifier = Modifier::empty(); + queue!(writer, SetBackgroundColor(clear_bg.into()))?; + bg = clear_bg; + queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?; + } + } + } + + queue!( + writer, + SetForegroundColor(crossterm::style::Color::Reset), + SetBackgroundColor(crossterm::style::Color::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + )?; + + Ok(()) +} + +/// The `ModifierDiff` struct is used to calculate the difference between two `Modifier` +/// values. This is useful when updating the terminal display, as it allows for more +/// efficient updates by only sending the necessary changes. +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, +} + +impl ModifierDiff { + fn queue(self, w: &mut W) -> io::Result<()> { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::NoReverse))?; + } + if removed.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + } + if removed.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::NoBlink))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::Reverse))?; + } + if added.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::Bold))?; + } + if added.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::Italic))?; + } + if added.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(w, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::RapidBlink))?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ratatui::layout::Rect; + use ratatui::style::Style; + + #[test] + fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { + let area = Rect::new(0, 0, 3, 2); + let previous = Buffer::empty(area); + let mut next = Buffer::empty(area); + + next.cell_mut((2, 0)) + .expect("cell should exist") + .set_symbol("X"); + + let commands = diff_buffers(&previous, &next); + + let clear_count = commands + .iter() + .filter(|command| matches!(command, DrawCommand::ClearToEnd { y, .. } if *y == 0)) + .count(); + assert_eq!( + 0, clear_count, + "expected diff_buffers not to emit ClearToEnd; commands: {commands:?}", + ); + assert!( + commands + .iter() + .any(|command| matches!(command, DrawCommand::Put { x: 2, y: 0, .. })), + "expected diff_buffers to update the final cell; commands: {commands:?}", + ); + } + + #[test] + fn diff_buffers_clear_to_end_starts_after_wide_char() { + let area = Rect::new(0, 0, 10, 1); + let mut previous = Buffer::empty(area); + let mut next = Buffer::empty(area); + + previous.set_string(0, 0, "中文", Style::default()); + next.set_string(0, 0, "中", Style::default()); + + let commands = diff_buffers(&previous, &next); + assert!( + commands + .iter() + .any(|command| matches!(command, DrawCommand::ClearToEnd { x: 2, y: 0, .. })), + "expected clear-to-end to start after the remaining wide char; commands: {commands:?}" + ); + } +} diff --git a/codex-rs/tui2/src/diff_render.rs b/codex-rs/tui2/src/diff_render.rs new file mode 100644 index 00000000000..24c5be597b7 --- /dev/null +++ b/codex-rs/tui2/src/diff_render.rs @@ -0,0 +1,673 @@ +use diffy::Hunk; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line as RtLine; +use ratatui::text::Span as RtSpan; +use ratatui::widgets::Paragraph; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use crate::exec_command::relativize_to_home; +use crate::render::Insets; +use crate::render::line_utils::prefix_lines; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::InsetRenderable; +use crate::render::renderable::Renderable; +use codex_core::git_info::get_git_repo_root; +use codex_core::protocol::FileChange; + +// Internal representation for diff line rendering +enum DiffLineType { + Insert, + Delete, + Context, +} + +pub struct DiffSummary { + changes: HashMap, + cwd: PathBuf, +} + +impl DiffSummary { + pub fn new(changes: HashMap, cwd: PathBuf) -> Self { + Self { changes, cwd } + } +} + +impl Renderable for FileChange { + fn render(&self, area: Rect, buf: &mut Buffer) { + let mut lines = vec![]; + render_change(self, &mut lines, area.width as usize); + Paragraph::new(lines).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let mut lines = vec![]; + render_change(self, &mut lines, width as usize); + lines.len() as u16 + } +} + +impl From for Box { + fn from(val: DiffSummary) -> Self { + let mut rows: Vec> = vec![]; + + for (i, row) in collect_rows(&val.changes).into_iter().enumerate() { + if i > 0 { + rows.push(Box::new(RtLine::from(""))); + } + let mut path = RtLine::from(display_path_for(&row.path, &val.cwd)); + path.push_span(" "); + path.extend(render_line_count_summary(row.added, row.removed)); + rows.push(Box::new(path)); + rows.push(Box::new(RtLine::from(""))); + rows.push(Box::new(InsetRenderable::new( + Box::new(row.change) as Box, + Insets::tlbr(0, 2, 0, 0), + ))); + } + + Box::new(ColumnRenderable::with(rows)) + } +} + +pub(crate) fn create_diff_summary( + changes: &HashMap, + cwd: &Path, + wrap_cols: usize, +) -> Vec> { + let rows = collect_rows(changes); + render_changes_block(rows, wrap_cols, cwd) +} + +// Shared row for per-file presentation +#[derive(Clone)] +struct Row { + #[allow(dead_code)] + path: PathBuf, + move_path: Option, + added: usize, + removed: usize, + change: FileChange, +} + +fn collect_rows(changes: &HashMap) -> Vec { + let mut rows: Vec = Vec::new(); + for (path, change) in changes.iter() { + let (added, removed) = match change { + FileChange::Add { content } => (content.lines().count(), 0), + FileChange::Delete { content } => (0, content.lines().count()), + FileChange::Update { unified_diff, .. } => calculate_add_remove_from_diff(unified_diff), + }; + let move_path = match change { + FileChange::Update { + move_path: Some(new), + .. + } => Some(new.clone()), + _ => None, + }; + rows.push(Row { + path: path.clone(), + move_path, + added, + removed, + change: change.clone(), + }); + } + rows.sort_by_key(|r| r.path.clone()); + rows +} + +fn render_line_count_summary(added: usize, removed: usize) -> Vec> { + let mut spans = Vec::new(); + spans.push("(".into()); + spans.push(format!("+{added}").green()); + spans.push(" ".into()); + spans.push(format!("-{removed}").red()); + spans.push(")".into()); + spans +} + +fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec> { + let mut out: Vec> = Vec::new(); + + let render_path = |row: &Row| -> Vec> { + let mut spans = Vec::new(); + spans.push(display_path_for(&row.path, cwd).into()); + if let Some(move_path) = &row.move_path { + spans.push(format!(" → {}", display_path_for(move_path, cwd)).into()); + } + spans + }; + + // Header + let total_added: usize = rows.iter().map(|r| r.added).sum(); + let total_removed: usize = rows.iter().map(|r| r.removed).sum(); + let file_count = rows.len(); + let noun = if file_count == 1 { "file" } else { "files" }; + let mut header_spans: Vec> = vec!["• ".dim()]; + if let [row] = &rows[..] { + let verb = match &row.change { + FileChange::Add { .. } => "Added", + FileChange::Delete { .. } => "Deleted", + _ => "Edited", + }; + header_spans.push(verb.bold()); + header_spans.push(" ".into()); + header_spans.extend(render_path(row)); + header_spans.push(" ".into()); + header_spans.extend(render_line_count_summary(row.added, row.removed)); + } else { + header_spans.push("Edited".bold()); + header_spans.push(format!(" {file_count} {noun} ").into()); + header_spans.extend(render_line_count_summary(total_added, total_removed)); + } + out.push(RtLine::from(header_spans)); + + for (idx, r) in rows.into_iter().enumerate() { + // Insert a blank separator between file chunks (except before the first) + if idx > 0 { + out.push("".into()); + } + // File header line (skip when single-file header already shows the name) + let skip_file_header = file_count == 1; + if !skip_file_header { + let mut header: Vec> = Vec::new(); + header.push(" └ ".dim()); + header.extend(render_path(&r)); + header.push(" ".into()); + header.extend(render_line_count_summary(r.added, r.removed)); + out.push(RtLine::from(header)); + } + + let mut lines = vec![]; + render_change(&r.change, &mut lines, wrap_cols - 4); + out.extend(prefix_lines(lines, " ".into(), " ".into())); + } + + out +} + +fn render_change(change: &FileChange, out: &mut Vec>, width: usize) { + match change { + FileChange::Add { content } => { + let line_number_width = line_number_width(content.lines().count()); + for (i, raw) in content.lines().enumerate() { + out.extend(push_wrapped_diff_line( + i + 1, + DiffLineType::Insert, + raw, + width, + line_number_width, + )); + } + } + FileChange::Delete { content } => { + let line_number_width = line_number_width(content.lines().count()); + for (i, raw) in content.lines().enumerate() { + out.extend(push_wrapped_diff_line( + i + 1, + DiffLineType::Delete, + raw, + width, + line_number_width, + )); + } + } + FileChange::Update { unified_diff, .. } => { + if let Ok(patch) = diffy::Patch::from_str(unified_diff) { + let mut max_line_number = 0; + for h in patch.hunks() { + let mut old_ln = h.old_range().start(); + let mut new_ln = h.new_range().start(); + for l in h.lines() { + match l { + diffy::Line::Insert(_) => { + max_line_number = max_line_number.max(new_ln); + new_ln += 1; + } + diffy::Line::Delete(_) => { + max_line_number = max_line_number.max(old_ln); + old_ln += 1; + } + diffy::Line::Context(_) => { + max_line_number = max_line_number.max(new_ln); + old_ln += 1; + new_ln += 1; + } + } + } + } + let line_number_width = line_number_width(max_line_number); + let mut is_first_hunk = true; + for h in patch.hunks() { + if !is_first_hunk { + let spacer = format!("{:width$} ", "", width = line_number_width.max(1)); + let spacer_span = RtSpan::styled(spacer, style_gutter()); + out.push(RtLine::from(vec![spacer_span, "⋮".dim()])); + } + is_first_hunk = false; + + let mut old_ln = h.old_range().start(); + let mut new_ln = h.new_range().start(); + for l in h.lines() { + match l { + diffy::Line::Insert(text) => { + let s = text.trim_end_matches('\n'); + out.extend(push_wrapped_diff_line( + new_ln, + DiffLineType::Insert, + s, + width, + line_number_width, + )); + new_ln += 1; + } + diffy::Line::Delete(text) => { + let s = text.trim_end_matches('\n'); + out.extend(push_wrapped_diff_line( + old_ln, + DiffLineType::Delete, + s, + width, + line_number_width, + )); + old_ln += 1; + } + diffy::Line::Context(text) => { + let s = text.trim_end_matches('\n'); + out.extend(push_wrapped_diff_line( + new_ln, + DiffLineType::Context, + s, + width, + line_number_width, + )); + old_ln += 1; + new_ln += 1; + } + } + } + } + } + } + } +} + +pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { + let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { + (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, + _ => false, + }; + let chosen = if path_in_same_repo { + pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf()) + } else { + relativize_to_home(path) + .map(|p| PathBuf::from_iter([Path::new("~"), p.as_path()])) + .unwrap_or_else(|| path.to_path_buf()) + }; + chosen.display().to_string() +} + +fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { + if let Ok(patch) = diffy::Patch::from_str(diff) { + patch + .hunks() + .iter() + .flat_map(Hunk::lines) + .fold((0, 0), |(a, d), l| match l { + diffy::Line::Insert(_) => (a + 1, d), + diffy::Line::Delete(_) => (a, d + 1), + diffy::Line::Context(_) => (a, d), + }) + } else { + // For unparsable diffs, return 0 for both counts. + (0, 0) + } +} + +fn push_wrapped_diff_line( + line_number: usize, + kind: DiffLineType, + text: &str, + width: usize, + line_number_width: usize, +) -> Vec> { + let ln_str = line_number.to_string(); + let mut remaining_text: &str = text; + + // Reserve a fixed number of spaces (equal to the widest line number plus a + // trailing spacer) so the sign column stays aligned across the diff block. + let gutter_width = line_number_width.max(1); + let prefix_cols = gutter_width + 1; + + let mut first = true; + let (sign_char, line_style) = match kind { + DiffLineType::Insert => ('+', style_add()), + DiffLineType::Delete => ('-', style_del()), + DiffLineType::Context => (' ', style_context()), + }; + let mut lines: Vec> = Vec::new(); + + loop { + // Fit the content for the current terminal row: + // compute how many columns are available after the prefix, then split + // at a UTF-8 character boundary so this row's chunk fits exactly. + let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1); + let split_at_byte_index = remaining_text + .char_indices() + .nth(available_content_cols) + .map(|(i, _)| i) + .unwrap_or_else(|| remaining_text.len()); + let (chunk, rest) = remaining_text.split_at(split_at_byte_index); + remaining_text = rest; + + if first { + // Build gutter (right-aligned line number plus spacer) as a dimmed span + let gutter = format!("{ln_str:>gutter_width$} "); + // Content with a sign ('+'/'-'/' ') styled per diff kind + let content = format!("{sign_char}{chunk}"); + lines.push(RtLine::from(vec![ + RtSpan::styled(gutter, style_gutter()), + RtSpan::styled(content, line_style), + ])); + first = false; + } else { + // Continuation lines keep a space for the sign column so content aligns + let gutter = format!("{:gutter_width$} ", ""); + lines.push(RtLine::from(vec![ + RtSpan::styled(gutter, style_gutter()), + RtSpan::styled(chunk.to_string(), line_style), + ])); + } + if remaining_text.is_empty() { + break; + } + } + lines +} + +fn line_number_width(max_line_number: usize) -> usize { + if max_line_number == 0 { + 1 + } else { + max_line_number.to_string().len() + } +} + +fn style_gutter() -> Style { + Style::default().add_modifier(Modifier::DIM) +} + +fn style_context() -> Style { + Style::default() +} + +fn style_add() -> Style { + Style::default().fg(Color::Green) +} + +fn style_del() -> Style { + Style::default().fg(Color::Red) +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::text::Text; + use ratatui::widgets::Paragraph; + use ratatui::widgets::WidgetRef; + use ratatui::widgets::Wrap; + fn diff_summary_for_tests(changes: &HashMap) -> Vec> { + create_diff_summary(changes, &PathBuf::from("/"), 80) + } + + fn snapshot_lines(name: &str, lines: Vec>, width: u16, height: u16) { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|f| { + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .render_ref(f.area(), f.buffer_mut()) + }) + .expect("draw"); + assert_snapshot!(name, terminal.backend()); + } + + fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) { + // Convert Lines to plain text rows and trim trailing spaces so it's + // easier to validate indentation visually in snapshots. + let text = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .map(|s| s.trim_end().to_string()) + .collect::>() + .join("\n"); + assert_snapshot!(name, text); + } + + #[test] + fn ui_snapshot_wrap_behavior_insert() { + // Narrow width to force wrapping within our diff line rendering + let long_line = "this is a very long line that should wrap across multiple terminal columns and continue"; + + // Call the wrapping function directly so we can precisely control the width + let lines = + push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80, line_number_width(1)); + + // Render into a small terminal to capture the visual layout + snapshot_lines("wrap_behavior_insert", lines, 90, 8); + } + + #[test] + fn ui_snapshot_apply_update_block() { + let mut changes: HashMap = HashMap::new(); + let original = "line one\nline two\nline three\n"; + let modified = "line one\nline two changed\nline three\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + changes.insert( + PathBuf::from("example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = diff_summary_for_tests(&changes); + + snapshot_lines("apply_update_block", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_update_with_rename_block() { + let mut changes: HashMap = HashMap::new(); + let original = "A\nB\nC\n"; + let modified = "A\nB changed\nC\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + changes.insert( + PathBuf::from("old_name.rs"), + FileChange::Update { + unified_diff: patch, + move_path: Some(PathBuf::from("new_name.rs")), + }, + ); + + let lines = diff_summary_for_tests(&changes); + + snapshot_lines("apply_update_with_rename_block", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_multiple_files_block() { + // Two files: one update and one add, to exercise combined header and per-file rows + let mut changes: HashMap = HashMap::new(); + + // File a.txt: single-line replacement (one delete, one insert) + let patch_a = diffy::create_patch("one\n", "one changed\n").to_string(); + changes.insert( + PathBuf::from("a.txt"), + FileChange::Update { + unified_diff: patch_a, + move_path: None, + }, + ); + + // File b.txt: newly added with one line + changes.insert( + PathBuf::from("b.txt"), + FileChange::Add { + content: "new\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests(&changes); + + snapshot_lines("apply_multiple_files_block", lines, 80, 14); + } + + #[test] + fn ui_snapshot_apply_add_block() { + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("new_file.txt"), + FileChange::Add { + content: "alpha\nbeta\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests(&changes); + + snapshot_lines("apply_add_block", lines, 80, 10); + } + + #[test] + fn ui_snapshot_apply_delete_block() { + // Write a temporary file so the delete renderer can read original content + let tmp_path = PathBuf::from("tmp_delete_example.txt"); + std::fs::write(&tmp_path, "first\nsecond\nthird\n").expect("write tmp file"); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + tmp_path.clone(), + FileChange::Delete { + content: "first\nsecond\nthird\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests(&changes); + + // Cleanup best-effort; rendering has already read the file + let _ = std::fs::remove_file(&tmp_path); + + snapshot_lines("apply_delete_block", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_update_block_wraps_long_lines() { + // Create a patch with a long modified line to force wrapping + let original = "line 1\nshort\nline 3\n"; + let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("long_example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72); + + // Render with backend width wider than wrap width to avoid Paragraph auto-wrap. + snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_update_block_wraps_long_lines_text() { + // This mirrors the desired layout example: sign only on first inserted line, + // subsequent wrapped pieces start aligned under the line number gutter. + let original = "1\n2\n3\n4\n"; + let modified = "1\nadded long line which wraps and_if_there_is_a_long_token_it_will_be_broken\n3\n4 context line which also wraps across\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("wrap_demo.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 28); + snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines); + } + + #[test] + fn ui_snapshot_apply_update_block_line_numbers_three_digits_text() { + let original = (1..=110).map(|i| format!("line {i}\n")).collect::(); + let modified = (1..=110) + .map(|i| { + if i == 100 { + format!("line {i} changed\n") + } else { + format!("line {i}\n") + } + }) + .collect::(); + let patch = diffy::create_patch(&original, &modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("hundreds.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); + snapshot_lines_text("apply_update_block_line_numbers_three_digits_text", &lines); + } + + #[test] + fn ui_snapshot_apply_update_block_relativizes_path() { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + let abs_old = cwd.join("abs_old.rs"); + let abs_new = cwd.join("abs_new.rs"); + + let original = "X\nY\n"; + let modified = "X changed\nY\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + abs_old, + FileChange::Update { + unified_diff: patch, + move_path: Some(abs_new), + }, + ); + + let lines = create_diff_summary(&changes, &cwd, 80); + + snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10); + } +} diff --git a/codex-rs/tui2/src/exec_cell/mod.rs b/codex-rs/tui2/src/exec_cell/mod.rs new file mode 100644 index 00000000000..906091113e9 --- /dev/null +++ b/codex-rs/tui2/src/exec_cell/mod.rs @@ -0,0 +1,12 @@ +mod model; +mod render; + +pub(crate) use model::CommandOutput; +#[cfg(test)] +pub(crate) use model::ExecCall; +pub(crate) use model::ExecCell; +pub(crate) use render::OutputLinesParams; +pub(crate) use render::TOOL_CALL_MAX_LINES; +pub(crate) use render::new_active_exec_command; +pub(crate) use render::output_lines; +pub(crate) use render::spinner; diff --git a/codex-rs/tui2/src/exec_cell/model.rs b/codex-rs/tui2/src/exec_cell/model.rs new file mode 100644 index 00000000000..76316968c6d --- /dev/null +++ b/codex-rs/tui2/src/exec_cell/model.rs @@ -0,0 +1,150 @@ +use std::time::Duration; +use std::time::Instant; + +use codex_core::protocol::ExecCommandSource; +use codex_protocol::parse_command::ParsedCommand; + +#[derive(Clone, Debug, Default)] +pub(crate) struct CommandOutput { + pub(crate) exit_code: i32, + /// The aggregated stderr + stdout interleaved. + pub(crate) aggregated_output: String, + /// The formatted output of the command, as seen by the model. + pub(crate) formatted_output: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ExecCall { + pub(crate) call_id: String, + pub(crate) command: Vec, + pub(crate) parsed: Vec, + pub(crate) output: Option, + pub(crate) source: ExecCommandSource, + pub(crate) start_time: Option, + pub(crate) duration: Option, + pub(crate) interaction_input: Option, +} + +#[derive(Debug)] +pub(crate) struct ExecCell { + pub(crate) calls: Vec, + animations_enabled: bool, +} + +impl ExecCell { + pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self { + Self { + calls: vec![call], + animations_enabled, + } + } + + pub(crate) fn with_added_call( + &self, + call_id: String, + command: Vec, + parsed: Vec, + source: ExecCommandSource, + interaction_input: Option, + ) -> Option { + let call = ExecCall { + call_id, + command, + parsed, + output: None, + source, + start_time: Some(Instant::now()), + duration: None, + interaction_input, + }; + if self.is_exploring_cell() && Self::is_exploring_call(&call) { + Some(Self { + calls: [self.calls.clone(), vec![call]].concat(), + animations_enabled: self.animations_enabled, + }) + } else { + None + } + } + + pub(crate) fn complete_call( + &mut self, + call_id: &str, + output: CommandOutput, + duration: Duration, + ) { + if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { + call.output = Some(output); + call.duration = Some(duration); + call.start_time = None; + } + } + + pub(crate) fn should_flush(&self) -> bool { + !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) + } + + pub(crate) fn mark_failed(&mut self) { + for call in self.calls.iter_mut() { + if call.output.is_none() { + let elapsed = call + .start_time + .map(|st| st.elapsed()) + .unwrap_or_else(|| Duration::from_millis(0)); + call.start_time = None; + call.duration = Some(elapsed); + call.output = Some(CommandOutput { + exit_code: 1, + formatted_output: String::new(), + aggregated_output: String::new(), + }); + } + } + } + + pub(crate) fn is_exploring_cell(&self) -> bool { + self.calls.iter().all(Self::is_exploring_call) + } + + pub(crate) fn is_active(&self) -> bool { + self.calls.iter().any(|c| c.output.is_none()) + } + + pub(crate) fn active_start_time(&self) -> Option { + self.calls + .iter() + .find(|c| c.output.is_none()) + .and_then(|c| c.start_time) + } + + pub(crate) fn animations_enabled(&self) -> bool { + self.animations_enabled + } + + pub(crate) fn iter_calls(&self) -> impl Iterator { + self.calls.iter() + } + + pub(super) fn is_exploring_call(call: &ExecCall) -> bool { + !matches!(call.source, ExecCommandSource::UserShell) + && !call.parsed.is_empty() + && call.parsed.iter().all(|p| { + matches!( + p, + ParsedCommand::Read { .. } + | ParsedCommand::ListFiles { .. } + | ParsedCommand::Search { .. } + ) + }) + } +} + +impl ExecCall { + pub(crate) fn is_user_shell_command(&self) -> bool { + matches!(self.source, ExecCommandSource::UserShell) + } + + pub(crate) fn is_unified_exec_interaction(&self) -> bool { + matches!(self.source, ExecCommandSource::UnifiedExecInteraction) + } +} diff --git a/codex-rs/tui2/src/exec_cell/render.rs b/codex-rs/tui2/src/exec_cell/render.rs new file mode 100644 index 00000000000..6517bcf470a --- /dev/null +++ b/codex-rs/tui2/src/exec_cell/render.rs @@ -0,0 +1,705 @@ +use std::time::Instant; + +use super::model::CommandOutput; +use super::model::ExecCall; +use super::model::ExecCell; +use crate::exec_command::strip_bash_lc_and_escape; +use crate::history_cell::HistoryCell; +use crate::render::highlight::highlight_bash_to_lines; +use crate::render::line_utils::prefix_lines; +use crate::render::line_utils::push_owned_lines; +use crate::shimmer::shimmer_spans; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; +use crate::wrapping::word_wrap_lines; +use codex_ansi_escape::ansi_escape_line; +use codex_common::elapsed::format_duration; +use codex_core::bash::extract_bash_command; +use codex_core::protocol::ExecCommandSource; +use codex_protocol::parse_command::ParsedCommand; +use itertools::Itertools; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::style::Stylize; +use textwrap::WordSplitter; +use unicode_width::UnicodeWidthStr; + +pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; +const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50; +const MAX_INTERACTION_PREVIEW_CHARS: usize = 80; + +pub(crate) struct OutputLinesParams { + pub(crate) line_limit: usize, + pub(crate) only_err: bool, + pub(crate) include_angle_pipe: bool, + pub(crate) include_prefix: bool, +} + +pub(crate) fn new_active_exec_command( + call_id: String, + command: Vec, + parsed: Vec, + source: ExecCommandSource, + interaction_input: Option, + animations_enabled: bool, +) -> ExecCell { + ExecCell::new( + ExecCall { + call_id, + command, + parsed, + output: None, + source, + start_time: Some(Instant::now()), + duration: None, + interaction_input, + }, + animations_enabled, + ) +} + +fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String { + let command_display = if let Some((_, script)) = extract_bash_command(command) { + script.to_string() + } else { + command.join(" ") + }; + match input { + Some(data) if !data.is_empty() => { + let preview = summarize_interaction_input(data); + format!("Interacted with `{command_display}`, sent `{preview}`") + } + _ => format!("Waited for `{command_display}`"), + } +} + +fn summarize_interaction_input(input: &str) -> String { + let single_line = input.replace('\n', "\\n"); + let sanitized = single_line.replace('`', "\\`"); + if sanitized.chars().count() <= MAX_INTERACTION_PREVIEW_CHARS { + return sanitized; + } + + let mut preview = String::new(); + for ch in sanitized.chars().take(MAX_INTERACTION_PREVIEW_CHARS) { + preview.push(ch); + } + preview.push_str("..."); + preview +} + +#[derive(Clone)] +pub(crate) struct OutputLines { + pub(crate) lines: Vec>, + pub(crate) omitted: Option, +} + +pub(crate) fn output_lines( + output: Option<&CommandOutput>, + params: OutputLinesParams, +) -> OutputLines { + let OutputLinesParams { + line_limit, + only_err, + include_angle_pipe, + include_prefix, + } = params; + let CommandOutput { + aggregated_output, .. + } = match output { + Some(output) if only_err && output.exit_code == 0 => { + return OutputLines { + lines: Vec::new(), + omitted: None, + }; + } + Some(output) => output, + None => { + return OutputLines { + lines: Vec::new(), + omitted: None, + }; + } + }; + + let src = aggregated_output; + let lines: Vec<&str> = src.lines().collect(); + let total = lines.len(); + let mut out: Vec> = Vec::new(); + + let head_end = total.min(line_limit); + for (i, raw) in lines[..head_end].iter().enumerate() { + let mut line = ansi_escape_line(raw); + let prefix = if !include_prefix { + "" + } else if i == 0 && include_angle_pipe { + " └ " + } else { + " " + }; + line.spans.insert(0, prefix.into()); + line.spans.iter_mut().for_each(|span| { + span.style = span.style.add_modifier(Modifier::DIM); + }); + out.push(line); + } + + let show_ellipsis = total > 2 * line_limit; + let omitted = if show_ellipsis { + Some(total - 2 * line_limit) + } else { + None + }; + if show_ellipsis { + let omitted = total - 2 * line_limit; + out.push(format!("… +{omitted} lines").into()); + } + + let tail_start = if show_ellipsis { + total - line_limit + } else { + head_end + }; + for raw in lines[tail_start..].iter() { + let mut line = ansi_escape_line(raw); + if include_prefix { + line.spans.insert(0, " ".into()); + } + line.spans.iter_mut().for_each(|span| { + span.style = span.style.add_modifier(Modifier::DIM); + }); + out.push(line); + } + + OutputLines { + lines: out, + omitted, + } +} + +pub(crate) fn spinner(start_time: Option, animations_enabled: bool) -> Span<'static> { + if !animations_enabled { + return "•".dim(); + } + let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default(); + if supports_color::on_cached(supports_color::Stream::Stdout) + .map(|level| level.has_16m) + .unwrap_or(false) + { + shimmer_spans("•")[0].clone() + } else { + let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2); + if blink_on { "•".into() } else { "◦".dim() } + } +} + +impl HistoryCell for ExecCell { + fn display_lines(&self, width: u16) -> Vec> { + if self.is_exploring_cell() { + self.exploring_display_lines(width) + } else { + self.command_display_lines(width) + } + } + + fn desired_transcript_height(&self, width: u16) -> u16 { + self.transcript_lines(width).len() as u16 + } + + fn transcript_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = vec![]; + for (i, call) in self.iter_calls().enumerate() { + if i > 0 { + lines.push("".into()); + } + let script = strip_bash_lc_and_escape(&call.command); + let highlighted_script = highlight_bash_to_lines(&script); + let cmd_display = word_wrap_lines( + &highlighted_script, + RtOptions::new(width as usize) + .initial_indent("$ ".magenta().into()) + .subsequent_indent(" ".into()), + ); + lines.extend(cmd_display); + + if let Some(output) = call.output.as_ref() { + if !call.is_unified_exec_interaction() { + let wrap_width = width.max(1) as usize; + let wrap_opts = RtOptions::new(wrap_width); + for unwrapped in output.formatted_output.lines().map(ansi_escape_line) { + let wrapped = word_wrap_line(&unwrapped, wrap_opts.clone()); + push_owned_lines(&wrapped, &mut lines); + } + } + let duration = call + .duration + .map(format_duration) + .unwrap_or_else(|| "unknown".to_string()); + let mut result: Line = if output.exit_code == 0 { + Line::from("✓".green().bold()) + } else { + Line::from(vec![ + "✗".red().bold(), + format!(" ({})", output.exit_code).into(), + ]) + }; + result.push_span(format!(" • {duration}").dim()); + lines.push(result); + } + } + lines + } +} + +impl ExecCell { + fn exploring_display_lines(&self, width: u16) -> Vec> { + let mut out: Vec> = Vec::new(); + out.push(Line::from(vec![ + if self.is_active() { + spinner(self.active_start_time(), self.animations_enabled()) + } else { + "•".dim() + }, + " ".into(), + if self.is_active() { + "Exploring".bold() + } else { + "Explored".bold() + }, + ])); + + let mut calls = self.calls.clone(); + let mut out_indented = Vec::new(); + while !calls.is_empty() { + let mut call = calls.remove(0); + if call + .parsed + .iter() + .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })) + { + while let Some(next) = calls.first() { + if next + .parsed + .iter() + .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })) + { + call.parsed.extend(next.parsed.clone()); + calls.remove(0); + } else { + break; + } + } + } + + let reads_only = call + .parsed + .iter() + .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })); + + let call_lines: Vec<(&str, Vec>)> = if reads_only { + let names = call + .parsed + .iter() + .map(|parsed| match parsed { + ParsedCommand::Read { name, .. } => name.clone(), + _ => unreachable!(), + }) + .unique(); + vec![( + "Read", + Itertools::intersperse(names.into_iter().map(Into::into), ", ".dim()).collect(), + )] + } else { + let mut lines = Vec::new(); + for parsed in &call.parsed { + match parsed { + ParsedCommand::Read { name, .. } => { + lines.push(("Read", vec![name.clone().into()])); + } + ParsedCommand::ListFiles { cmd, path } => { + lines.push(("List", vec![path.clone().unwrap_or(cmd.clone()).into()])); + } + ParsedCommand::Search { cmd, query, path } => { + let spans = match (query, path) { + (Some(q), Some(p)) => { + vec![q.clone().into(), " in ".dim(), p.clone().into()] + } + (Some(q), None) => vec![q.clone().into()], + _ => vec![cmd.clone().into()], + }; + lines.push(("Search", spans)); + } + ParsedCommand::Unknown { cmd } => { + lines.push(("Run", vec![cmd.clone().into()])); + } + } + } + lines + }; + + for (title, line) in call_lines { + let line = Line::from(line); + let initial_indent = Line::from(vec![title.cyan(), " ".into()]); + let subsequent_indent = " ".repeat(initial_indent.width()).into(); + let wrapped = word_wrap_line( + &line, + RtOptions::new(width as usize) + .initial_indent(initial_indent) + .subsequent_indent(subsequent_indent), + ); + push_owned_lines(&wrapped, &mut out_indented); + } + } + + out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into())); + out + } + + fn command_display_lines(&self, width: u16) -> Vec> { + let [call] = &self.calls.as_slice() else { + panic!("Expected exactly one call in a command display cell"); + }; + let layout = EXEC_DISPLAY_LAYOUT; + let success = call.output.as_ref().map(|o| o.exit_code == 0); + let bullet = match success { + Some(true) => "•".green().bold(), + Some(false) => "•".red().bold(), + None => spinner(call.start_time, self.animations_enabled()), + }; + let is_interaction = call.is_unified_exec_interaction(); + let title = if is_interaction { + "" + } else if self.is_active() { + "Running" + } else if call.is_user_shell_command() { + "You ran" + } else { + "Ran" + }; + + let mut header_line = if is_interaction { + Line::from(vec![bullet.clone(), " ".into()]) + } else { + Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]) + }; + let header_prefix_width = header_line.width(); + + let cmd_display = if call.is_unified_exec_interaction() { + format_unified_exec_interaction(&call.command, call.interaction_input.as_deref()) + } else { + strip_bash_lc_and_escape(&call.command) + }; + let highlighted_lines = highlight_bash_to_lines(&cmd_display); + + let continuation_wrap_width = layout.command_continuation.wrap_width(width); + let continuation_opts = + RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation); + + let mut continuation_lines: Vec> = Vec::new(); + + if let Some((first, rest)) = highlighted_lines.split_first() { + let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1); + let first_opts = + RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation); + let mut first_wrapped: Vec> = Vec::new(); + push_owned_lines(&word_wrap_line(first, first_opts), &mut first_wrapped); + let mut first_wrapped_iter = first_wrapped.into_iter(); + if let Some(first_segment) = first_wrapped_iter.next() { + header_line.extend(first_segment); + } + continuation_lines.extend(first_wrapped_iter); + + for line in rest { + push_owned_lines( + &word_wrap_line(line, continuation_opts.clone()), + &mut continuation_lines, + ); + } + } + + let mut lines: Vec> = vec![header_line]; + + let continuation_lines = Self::limit_lines_from_start( + &continuation_lines, + layout.command_continuation_max_lines, + ); + if !continuation_lines.is_empty() { + lines.extend(prefix_lines( + continuation_lines, + Span::from(layout.command_continuation.initial_prefix).dim(), + Span::from(layout.command_continuation.subsequent_prefix).dim(), + )); + } + + if let Some(output) = call.output.as_ref() { + let line_limit = if call.is_user_shell_command() { + USER_SHELL_TOOL_CALL_MAX_LINES + } else { + TOOL_CALL_MAX_LINES + }; + let raw_output = output_lines( + Some(output), + OutputLinesParams { + line_limit, + only_err: false, + include_angle_pipe: false, + include_prefix: false, + }, + ); + let display_limit = if call.is_user_shell_command() { + USER_SHELL_TOOL_CALL_MAX_LINES + } else { + layout.output_max_lines + }; + + if raw_output.lines.is_empty() { + if !call.is_unified_exec_interaction() { + lines.extend(prefix_lines( + vec![Line::from("(no output)".dim())], + Span::from(layout.output_block.initial_prefix).dim(), + Span::from(layout.output_block.subsequent_prefix), + )); + } + } else { + // Wrap first so that truncation is applied to on-screen lines + // rather than logical lines. This ensures that a small number + // of very long lines cannot flood the viewport. + let mut wrapped_output: Vec> = Vec::new(); + let output_wrap_width = layout.output_block.wrap_width(width); + let output_opts = + RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); + for line in &raw_output.lines { + push_owned_lines( + &word_wrap_line(line, output_opts.clone()), + &mut wrapped_output, + ); + } + + let trimmed_output = + Self::truncate_lines_middle(&wrapped_output, display_limit, raw_output.omitted); + + if !trimmed_output.is_empty() { + lines.extend(prefix_lines( + trimmed_output, + Span::from(layout.output_block.initial_prefix).dim(), + Span::from(layout.output_block.subsequent_prefix), + )); + } + } + } + + lines + } + + fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec> { + if lines.len() <= keep { + return lines.to_vec(); + } + if keep == 0 { + return vec![Self::ellipsis_line(lines.len())]; + } + + let mut out: Vec> = lines[..keep].to_vec(); + out.push(Self::ellipsis_line(lines.len() - keep)); + out + } + + fn truncate_lines_middle( + lines: &[Line<'static>], + max: usize, + omitted_hint: Option, + ) -> Vec> { + if max == 0 { + return Vec::new(); + } + if lines.len() <= max { + return lines.to_vec(); + } + if max == 1 { + // Carry forward any previously omitted count and add any + // additionally hidden content lines from this truncation. + let base = omitted_hint.unwrap_or(0); + // When an existing ellipsis is present, `lines` already includes + // that single representation line; exclude it from the count of + // additionally omitted content lines. + let extra = lines + .len() + .saturating_sub(usize::from(omitted_hint.is_some())); + let omitted = base + extra; + return vec![Self::ellipsis_line(omitted)]; + } + + let head = (max - 1) / 2; + let tail = max - head - 1; + let mut out: Vec> = Vec::new(); + + if head > 0 { + out.extend(lines[..head].iter().cloned()); + } + + let base = omitted_hint.unwrap_or(0); + let additional = lines + .len() + .saturating_sub(head + tail) + .saturating_sub(usize::from(omitted_hint.is_some())); + out.push(Self::ellipsis_line(base + additional)); + + if tail > 0 { + out.extend(lines[lines.len() - tail..].iter().cloned()); + } + + out + } + + fn ellipsis_line(omitted: usize) -> Line<'static> { + Line::from(vec![format!("… +{omitted} lines").dim()]) + } +} + +#[derive(Clone, Copy)] +struct PrefixedBlock { + initial_prefix: &'static str, + subsequent_prefix: &'static str, +} + +impl PrefixedBlock { + const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self { + Self { + initial_prefix, + subsequent_prefix, + } + } + + fn wrap_width(self, total_width: u16) -> usize { + let prefix_width = UnicodeWidthStr::width(self.initial_prefix) + .max(UnicodeWidthStr::width(self.subsequent_prefix)); + usize::from(total_width).saturating_sub(prefix_width).max(1) + } +} + +#[derive(Clone, Copy)] +struct ExecDisplayLayout { + command_continuation: PrefixedBlock, + command_continuation_max_lines: usize, + output_block: PrefixedBlock, + output_max_lines: usize, +} + +impl ExecDisplayLayout { + const fn new( + command_continuation: PrefixedBlock, + command_continuation_max_lines: usize, + output_block: PrefixedBlock, + output_max_lines: usize, + ) -> Self { + Self { + command_continuation, + command_continuation_max_lines, + output_block, + output_max_lines, + } + } +} + +const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( + PrefixedBlock::new(" │ ", " │ "), + 2, + PrefixedBlock::new(" └ ", " "), + 5, +); + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::protocol::ExecCommandSource; + + #[test] + fn user_shell_output_is_limited_by_screen_lines() { + // Construct a user shell exec cell whose aggregated output consists of a + // small number of very long logical lines. These will wrap into many + // on-screen lines at narrow widths. + // + // Use a short marker so it survives wrapping intact inside each + // rendered screen line; the previous test used a marker longer than + // the wrap width, so it was split across lines and the assertion + // never actually saw it. + let marker = "Z"; + let long_chunk = marker.repeat(800); + let aggregated_output = format!("{long_chunk}\n{long_chunk}\n"); + + // Baseline: how many screen lines would we get if we simply wrapped + // all logical lines without any truncation? + let output = CommandOutput { + exit_code: 0, + aggregated_output, + formatted_output: String::new(), + }; + let width = 20; + let layout = EXEC_DISPLAY_LAYOUT; + let raw_output = output_lines( + Some(&output), + OutputLinesParams { + // Large enough to include all logical lines without + // triggering the ellipsis in `output_lines`. + line_limit: 100, + only_err: false, + include_angle_pipe: false, + include_prefix: false, + }, + ); + let output_wrap_width = layout.output_block.wrap_width(width); + let output_opts = + RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); + let mut full_wrapped_output: Vec> = Vec::new(); + for line in &raw_output.lines { + push_owned_lines( + &word_wrap_line(line, output_opts.clone()), + &mut full_wrapped_output, + ); + } + let full_screen_lines = full_wrapped_output + .iter() + .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) + .count(); + + // Sanity check: this scenario should produce more screen lines than + // the user shell per-call limit when no truncation is applied. If + // this ever fails, the test no longer exercises the regression. + assert!( + full_screen_lines > USER_SHELL_TOOL_CALL_MAX_LINES, + "expected unbounded wrapping to produce more than {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines, got {full_screen_lines}", + ); + + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo long".into()], + parsed: Vec::new(), + output: Some(output), + source: ExecCommandSource::UserShell, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + + // Use a narrow width so each logical line wraps into many on-screen lines. + let lines = cell.command_display_lines(width); + + // Count how many rendered lines contain our marker text. This approximates + // the number of visible output "screen lines" for this command. + let output_screen_lines = lines + .iter() + .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) + .count(); + + // Regression guard: previously this scenario could render hundreds of + // wrapped lines because truncation happened before wrapping. Now the + // truncation is applied after wrapping, so the number of visible + // screen lines is bounded by USER_SHELL_TOOL_CALL_MAX_LINES. + assert!( + output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES, + "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines of user shell output, got {output_screen_lines}", + ); + } +} diff --git a/codex-rs/tui2/src/exec_command.rs b/codex-rs/tui2/src/exec_command.rs new file mode 100644 index 00000000000..8ce6c2632e4 --- /dev/null +++ b/codex-rs/tui2/src/exec_command.rs @@ -0,0 +1,70 @@ +use std::path::Path; +use std::path::PathBuf; + +use codex_core::parse_command::extract_shell_command; +use dirs::home_dir; +use shlex::try_join; + +pub(crate) fn escape_command(command: &[String]) -> String { + try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) +} + +pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { + if let Some((_, script)) = extract_shell_command(command) { + return script.to_string(); + } + escape_command(command) +} + +/// If `path` is absolute and inside $HOME, return the part *after* the home +/// directory; otherwise, return the path as-is. Note if `path` is the homedir, +/// this will return and empty path. +pub(crate) fn relativize_to_home

(path: P) -> Option +where + P: AsRef, +{ + let path = path.as_ref(); + if !path.is_absolute() { + // If the path is not absolute, we can’t do anything with it. + return None; + } + + let home_dir = home_dir()?; + let rel = path.strip_prefix(&home_dir).ok()?; + Some(rel.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_escape_command() { + let args = vec!["foo".into(), "bar baz".into(), "weird&stuff".into()]; + let cmdline = escape_command(&args); + assert_eq!(cmdline, "foo 'bar baz' 'weird&stuff'"); + } + + #[test] + fn test_strip_bash_lc_and_escape() { + // Test bash + let args = vec!["bash".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test zsh + let args = vec!["zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to zsh + let args = vec!["/usr/bin/zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to bash + let args = vec!["/bin/bash".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + } +} diff --git a/codex-rs/tui2/src/file_search.rs b/codex-rs/tui2/src/file_search.rs new file mode 100644 index 00000000000..af465126400 --- /dev/null +++ b/codex-rs/tui2/src/file_search.rs @@ -0,0 +1,199 @@ +//! Helper that owns the debounce/cancellation logic for `@` file searches. +//! +//! `ChatComposer` publishes *every* change of the `@token` as +//! `AppEvent::StartFileSearch(query)`. +//! This struct receives those events and decides when to actually spawn the +//! expensive search (handled in the main `App` thread). It tries to ensure: +//! +//! - Even when the user types long text quickly, they will start seeing results +//! after a short delay using an early version of what they typed. +//! - At most one search is in-flight at any time. +//! +//! It works as follows: +//! +//! 1. First query starts a debounce timer. +//! 2. While the timer is pending, the latest query from the user is stored. +//! 3. When the timer fires, it is cleared, and a search is done for the most +//! recent query. +//! 4. If there is a in-flight search that is not a prefix of the latest thing +//! the user typed, it is cancelled. + +use codex_file_search as file_search; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(20).unwrap(); +const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap(); + +/// How long to wait after a keystroke before firing the first search when none +/// is currently running. Keeps early queries more meaningful. +const FILE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); + +const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// State machine for file-search orchestration. +pub(crate) struct FileSearchManager { + /// Unified state guarded by one mutex. + state: Arc>, + + search_dir: PathBuf, + app_tx: AppEventSender, +} + +struct SearchState { + /// Latest query typed by user (updated every keystroke). + latest_query: String, + + /// true if a search is currently scheduled. + is_search_scheduled: bool, + + /// If there is an active search, this will be the query being searched. + active_search: Option, +} + +struct ActiveSearch { + query: String, + cancellation_token: Arc, +} + +impl FileSearchManager { + pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self { + Self { + state: Arc::new(Mutex::new(SearchState { + latest_query: String::new(), + is_search_scheduled: false, + active_search: None, + })), + search_dir, + app_tx: tx, + } + } + + /// Call whenever the user edits the `@` token. + pub fn on_user_query(&self, query: String) { + { + #[expect(clippy::unwrap_used)] + let mut st = self.state.lock().unwrap(); + if query == st.latest_query { + // No change, nothing to do. + return; + } + + // Update latest query. + st.latest_query.clear(); + st.latest_query.push_str(&query); + + // If there is an in-flight search that is definitely obsolete, + // cancel it now. + if let Some(active_search) = &st.active_search + && !query.starts_with(&active_search.query) + { + active_search + .cancellation_token + .store(true, Ordering::Relaxed); + st.active_search = None; + } + + // Schedule a search to run after debounce. + if !st.is_search_scheduled { + st.is_search_scheduled = true; + } else { + return; + } + } + + // If we are here, we set `st.is_search_scheduled = true` before + // dropping the lock. This means we are the only thread that can spawn a + // debounce timer. + let state = self.state.clone(); + let search_dir = self.search_dir.clone(); + let tx_clone = self.app_tx.clone(); + thread::spawn(move || { + // Always do a minimum debounce, but then poll until the + // `active_search` is cleared. + thread::sleep(FILE_SEARCH_DEBOUNCE); + loop { + #[expect(clippy::unwrap_used)] + if state.lock().unwrap().active_search.is_none() { + break; + } + thread::sleep(ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL); + } + + // The debounce timer has expired, so start a search using the + // latest query. + let cancellation_token = Arc::new(AtomicBool::new(false)); + let token = cancellation_token.clone(); + let query = { + #[expect(clippy::unwrap_used)] + let mut st = state.lock().unwrap(); + let query = st.latest_query.clone(); + st.is_search_scheduled = false; + st.active_search = Some(ActiveSearch { + query: query.clone(), + cancellation_token: token, + }); + query + }; + + FileSearchManager::spawn_file_search( + query, + search_dir, + tx_clone, + cancellation_token, + state, + ); + }); + } + + fn spawn_file_search( + query: String, + search_dir: PathBuf, + tx: AppEventSender, + cancellation_token: Arc, + search_state: Arc>, + ) { + let compute_indices = true; + std::thread::spawn(move || { + let matches = file_search::run( + &query, + MAX_FILE_SEARCH_RESULTS, + &search_dir, + Vec::new(), + NUM_FILE_SEARCH_THREADS, + cancellation_token.clone(), + compute_indices, + true, + ) + .map(|res| res.matches) + .unwrap_or_default(); + + let is_cancelled = cancellation_token.load(Ordering::Relaxed); + if !is_cancelled { + tx.send(AppEvent::FileSearchResult { query, matches }); + } + + // Reset the active search state. Do a pointer comparison to verify + // that we are clearing the ActiveSearch that corresponds to the + // cancellation token we were given. + { + #[expect(clippy::unwrap_used)] + let mut st = search_state.lock().unwrap(); + if let Some(active_search) = &st.active_search + && Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token) + { + st.active_search = None; + } + } + }); + } +} diff --git a/codex-rs/tui2/src/frames.rs b/codex-rs/tui2/src/frames.rs new file mode 100644 index 00000000000..19a70578d48 --- /dev/null +++ b/codex-rs/tui2/src/frames.rs @@ -0,0 +1,71 @@ +use std::time::Duration; + +// Embed animation frames for each variant at compile time. +macro_rules! frames_for { + ($dir:literal) => { + [ + include_str!(concat!("../frames/", $dir, "/frame_1.txt")), + include_str!(concat!("../frames/", $dir, "/frame_2.txt")), + include_str!(concat!("../frames/", $dir, "/frame_3.txt")), + include_str!(concat!("../frames/", $dir, "/frame_4.txt")), + include_str!(concat!("../frames/", $dir, "/frame_5.txt")), + include_str!(concat!("../frames/", $dir, "/frame_6.txt")), + include_str!(concat!("../frames/", $dir, "/frame_7.txt")), + include_str!(concat!("../frames/", $dir, "/frame_8.txt")), + include_str!(concat!("../frames/", $dir, "/frame_9.txt")), + include_str!(concat!("../frames/", $dir, "/frame_10.txt")), + include_str!(concat!("../frames/", $dir, "/frame_11.txt")), + include_str!(concat!("../frames/", $dir, "/frame_12.txt")), + include_str!(concat!("../frames/", $dir, "/frame_13.txt")), + include_str!(concat!("../frames/", $dir, "/frame_14.txt")), + include_str!(concat!("../frames/", $dir, "/frame_15.txt")), + include_str!(concat!("../frames/", $dir, "/frame_16.txt")), + include_str!(concat!("../frames/", $dir, "/frame_17.txt")), + include_str!(concat!("../frames/", $dir, "/frame_18.txt")), + include_str!(concat!("../frames/", $dir, "/frame_19.txt")), + include_str!(concat!("../frames/", $dir, "/frame_20.txt")), + include_str!(concat!("../frames/", $dir, "/frame_21.txt")), + include_str!(concat!("../frames/", $dir, "/frame_22.txt")), + include_str!(concat!("../frames/", $dir, "/frame_23.txt")), + include_str!(concat!("../frames/", $dir, "/frame_24.txt")), + include_str!(concat!("../frames/", $dir, "/frame_25.txt")), + include_str!(concat!("../frames/", $dir, "/frame_26.txt")), + include_str!(concat!("../frames/", $dir, "/frame_27.txt")), + include_str!(concat!("../frames/", $dir, "/frame_28.txt")), + include_str!(concat!("../frames/", $dir, "/frame_29.txt")), + include_str!(concat!("../frames/", $dir, "/frame_30.txt")), + include_str!(concat!("../frames/", $dir, "/frame_31.txt")), + include_str!(concat!("../frames/", $dir, "/frame_32.txt")), + include_str!(concat!("../frames/", $dir, "/frame_33.txt")), + include_str!(concat!("../frames/", $dir, "/frame_34.txt")), + include_str!(concat!("../frames/", $dir, "/frame_35.txt")), + include_str!(concat!("../frames/", $dir, "/frame_36.txt")), + ] + }; +} + +pub(crate) const FRAMES_DEFAULT: [&str; 36] = frames_for!("default"); +pub(crate) const FRAMES_CODEX: [&str; 36] = frames_for!("codex"); +pub(crate) const FRAMES_OPENAI: [&str; 36] = frames_for!("openai"); +pub(crate) const FRAMES_BLOCKS: [&str; 36] = frames_for!("blocks"); +pub(crate) const FRAMES_DOTS: [&str; 36] = frames_for!("dots"); +pub(crate) const FRAMES_HASH: [&str; 36] = frames_for!("hash"); +pub(crate) const FRAMES_HBARS: [&str; 36] = frames_for!("hbars"); +pub(crate) const FRAMES_VBARS: [&str; 36] = frames_for!("vbars"); +pub(crate) const FRAMES_SHAPES: [&str; 36] = frames_for!("shapes"); +pub(crate) const FRAMES_SLUG: [&str; 36] = frames_for!("slug"); + +pub(crate) const ALL_VARIANTS: &[&[&str]] = &[ + &FRAMES_DEFAULT, + &FRAMES_CODEX, + &FRAMES_OPENAI, + &FRAMES_BLOCKS, + &FRAMES_DOTS, + &FRAMES_HASH, + &FRAMES_HBARS, + &FRAMES_VBARS, + &FRAMES_SHAPES, + &FRAMES_SLUG, +]; + +pub(crate) const FRAME_TICK_DEFAULT: Duration = Duration::from_millis(80); diff --git a/codex-rs/tui2/src/get_git_diff.rs b/codex-rs/tui2/src/get_git_diff.rs new file mode 100644 index 00000000000..78ab53d92f6 --- /dev/null +++ b/codex-rs/tui2/src/get_git_diff.rs @@ -0,0 +1,119 @@ +//! Utility to compute the current Git diff for the working directory. +//! +//! The implementation mirrors the behaviour of the TypeScript version in +//! `codex-cli`: it returns the diff for tracked changes as well as any +//! untracked files. When the current directory is not inside a Git +//! repository, the function returns `Ok((false, String::new()))`. + +use std::io; +use std::path::Path; +use std::process::Stdio; +use tokio::process::Command; + +/// Return value of [`get_git_diff`]. +/// +/// * `bool` – Whether the current working directory is inside a Git repo. +/// * `String` – The concatenated diff (may be empty). +pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> { + // First check if we are inside a Git repository. + if !inside_git_repo().await? { + return Ok((false, String::new())); + } + + // Run tracked diff and untracked file listing in parallel. + let (tracked_diff_res, untracked_output_res) = tokio::join!( + run_git_capture_diff(&["diff", "--color"]), + run_git_capture_stdout(&["ls-files", "--others", "--exclude-standard"]), + ); + let tracked_diff = tracked_diff_res?; + let untracked_output = untracked_output_res?; + + let mut untracked_diff = String::new(); + let null_device: &Path = if cfg!(windows) { + Path::new("NUL") + } else { + Path::new("/dev/null") + }; + + let null_path = null_device.to_str().unwrap_or("/dev/null").to_string(); + let mut join_set: tokio::task::JoinSet> = tokio::task::JoinSet::new(); + for file in untracked_output + .split('\n') + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let null_path = null_path.clone(); + let file = file.to_string(); + join_set.spawn(async move { + let args = ["diff", "--color", "--no-index", "--", &null_path, &file]; + run_git_capture_diff(&args).await + }); + } + while let Some(res) = join_set.join_next().await { + match res { + Ok(Ok(diff)) => untracked_diff.push_str(&diff), + Ok(Err(err)) if err.kind() == io::ErrorKind::NotFound => {} + Ok(Err(err)) => return Err(err), + Err(_) => {} + } + } + + Ok((true, format!("{tracked_diff}{untracked_diff}"))) +} + +/// Helper that executes `git` with the given `args` and returns `stdout` as a +/// UTF-8 string. Any non-zero exit status is considered an *error*. +async fn run_git_capture_stdout(args: &[&str]) -> io::Result { + let output = Command::new("git") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(io::Error::other(format!( + "git {:?} failed with status {}", + args, output.status + ))) + } +} + +/// Like [`run_git_capture_stdout`] but treats exit status 1 as success and +/// returns stdout. Git returns 1 for diffs when differences are present. +async fn run_git_capture_diff(args: &[&str]) -> io::Result { + let output = Command::new("git") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await?; + + if output.status.success() || output.status.code() == Some(1) { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(io::Error::other(format!( + "git {:?} failed with status {}", + args, output.status + ))) + } +} + +/// Determine if the current directory is inside a Git repository. +async fn inside_git_repo() -> io::Result { + let status = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + match status { + Ok(s) if s.success() => Ok(true), + Ok(_) => Ok(false), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), // git not installed + Err(e) => Err(e), + } +} diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs new file mode 100644 index 00000000000..41470673668 --- /dev/null +++ b/codex-rs/tui2/src/history_cell.rs @@ -0,0 +1,2435 @@ +use crate::diff_render::create_diff_summary; +use crate::diff_render::display_path_for; +use crate::exec_cell::CommandOutput; +use crate::exec_cell::OutputLinesParams; +use crate::exec_cell::TOOL_CALL_MAX_LINES; +use crate::exec_cell::output_lines; +use crate::exec_cell::spinner; +use crate::exec_command::relativize_to_home; +use crate::exec_command::strip_bash_lc_and_escape; +use crate::markdown::append_markdown; +use crate::render::line_utils::line_to_static; +use crate::render::line_utils::prefix_lines; +use crate::render::line_utils::push_owned_lines; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; +use crate::text_formatting::format_and_truncate_tool_result; +use crate::text_formatting::truncate_text; +use crate::tooltips; +use crate::ui_consts::LIVE_PREFIX_COLS; +use crate::update_action::UpdateAction; +use crate::version::CODEX_CLI_VERSION; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; +use crate::wrapping::word_wrap_lines; +use base64::Engine; +use codex_common::format_env_display::format_env_display; +use codex_core::config::Config; +use codex_core::config::types::McpServerTransportConfig; +use codex_core::config::types::ReasoningSummaryFormat; +use codex_core::protocol::FileChange; +use codex_core::protocol::McpAuthStatus; +use codex_core::protocol::McpInvocation; +use codex_core::protocol::SessionConfiguredEvent; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::plan_tool::PlanItemArg; +use codex_protocol::plan_tool::StepStatus; +use codex_protocol::plan_tool::UpdatePlanArgs; +use image::DynamicImage; +use image::ImageReader; +use mcp_types::EmbeddedResourceResource; +use mcp_types::Resource; +use mcp_types::ResourceLink; +use mcp_types::ResourceTemplate; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use std::any::Any; +use std::collections::HashMap; +use std::io::Cursor; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; +use tracing::error; +use unicode_width::UnicodeWidthStr; + +/// 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 + Any { + fn display_lines(&self, width: u16) -> Vec>; + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(Text::from(self.display_lines(width))) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(0) + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.display_lines(width) + } + + fn desired_transcript_height(&self, width: u16) -> u16 { + let lines = self.transcript_lines(width); + // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. + if let [line] = &lines[..] + && line + .spans + .iter() + .all(|s| s.content.chars().all(char::is_whitespace)) + { + return 1; + } + + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(0) + } + + fn is_stream_continuation(&self) -> bool { + false + } +} + +impl Renderable for Box { + fn render(&self, area: Rect, buf: &mut Buffer) { + let lines = self.display_lines(area.width); + let y = if area.height == 0 { + 0 + } else { + let overflow = lines.len().saturating_sub(usize::from(area.height)); + u16::try_from(overflow).unwrap_or(u16::MAX) + }; + Paragraph::new(Text::from(lines)) + .scroll((y, 0)) + .render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + HistoryCell::desired_height(self.as_ref(), width) + } +} + +impl dyn HistoryCell { + pub(crate) fn as_any(&self) -> &dyn Any { + self + } + + pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +#[derive(Debug)] +pub(crate) struct UserHistoryCell { + pub message: String, +} + +impl HistoryCell for UserHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + + let wrap_width = width + .saturating_sub( + LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ + ) + .max(1); + + let style = user_message_style(); + + let wrapped = word_wrap_lines( + self.message.lines().map(|l| Line::from(l).style(style)), + // Wrap algorithm matches textarea.rs. + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ); + + lines.push(Line::from("").style(style)); + lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); + lines.push(Line::from("").style(style)); + lines + } +} + +#[derive(Debug)] +pub(crate) struct ReasoningSummaryCell { + _header: String, + content: String, + transcript_only: bool, +} + +impl ReasoningSummaryCell { + pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self { + Self { + _header: header, + content, + transcript_only, + } + } + + fn lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + append_markdown( + &self.content, + Some((width as usize).saturating_sub(2)), + &mut lines, + ); + let summary_style = Style::default().dim().italic(); + let summary_lines = lines + .into_iter() + .map(|mut line| { + line.spans = line + .spans + .into_iter() + .map(|span| span.patch_style(summary_style)) + .collect(); + line + }) + .collect::>(); + + word_wrap_lines( + &summary_lines, + RtOptions::new(width as usize) + .initial_indent("• ".dim().into()) + .subsequent_indent(" ".into()), + ) + } +} + +impl HistoryCell for ReasoningSummaryCell { + fn display_lines(&self, width: u16) -> Vec> { + if self.transcript_only { + Vec::new() + } else { + self.lines(width) + } + } + + fn desired_height(&self, width: u16) -> u16 { + if self.transcript_only { + 0 + } else { + self.lines(width).len() as u16 + } + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.lines(width) + } + + fn desired_transcript_height(&self, width: u16) -> u16 { + self.lines(width).len() as u16 + } +} + +#[derive(Debug)] +pub(crate) struct AgentMessageCell { + lines: Vec>, + is_first_line: bool, +} + +impl AgentMessageCell { + pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + Self { + lines, + is_first_line, + } + } +} + +impl HistoryCell for AgentMessageCell { + fn display_lines(&self, width: u16) -> Vec> { + word_wrap_lines( + &self.lines, + RtOptions::new(width as usize) + .initial_indent(if self.is_first_line { + "• ".dim().into() + } else { + " ".into() + }) + .subsequent_indent(" ".into()), + ) + } + + fn is_stream_continuation(&self) -> bool { + !self.is_first_line + } +} + +#[derive(Debug)] +pub(crate) struct PlainHistoryCell { + lines: Vec>, +} + +impl PlainHistoryCell { + pub(crate) fn new(lines: Vec>) -> Self { + Self { lines } + } +} + +impl HistoryCell for PlainHistoryCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } +} + +#[cfg_attr(debug_assertions, allow(dead_code))] +#[derive(Debug)] +pub(crate) struct UpdateAvailableHistoryCell { + latest_version: String, + update_action: Option, +} + +#[cfg_attr(debug_assertions, allow(dead_code))] +impl UpdateAvailableHistoryCell { + pub(crate) fn new(latest_version: String, update_action: Option) -> Self { + Self { + latest_version, + update_action, + } + } +} + +impl HistoryCell for UpdateAvailableHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + use ratatui_macros::line; + use ratatui_macros::text; + let update_instruction = if let Some(update_action) = self.update_action { + line!["Run ", update_action.command_str().cyan(), " to update."] + } else { + line![ + "See ", + "https://github.com/openai/codex".cyan().underlined(), + " for installation options." + ] + }; + + let content = text![ + line![ + padded_emoji("✨").bold().cyan(), + "Update available!".bold().cyan(), + " ", + format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), + ], + update_instruction, + "", + "See full release notes:", + "https://github.com/openai/codex/releases/latest" + .cyan() + .underlined(), + ]; + + let inner_width = content + .width() + .min(usize::from(width.saturating_sub(4))) + .max(1); + with_border_with_inner_width(content.lines, inner_width) + } +} + +#[derive(Debug)] +pub(crate) struct PrefixedWrappedHistoryCell { + text: Text<'static>, + initial_prefix: Line<'static>, + subsequent_prefix: Line<'static>, +} + +impl PrefixedWrappedHistoryCell { + pub(crate) fn new( + text: impl Into>, + initial_prefix: impl Into>, + subsequent_prefix: impl Into>, + ) -> Self { + Self { + text: text.into(), + initial_prefix: initial_prefix.into(), + subsequent_prefix: subsequent_prefix.into(), + } + } +} + +impl HistoryCell for PrefixedWrappedHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + if width == 0 { + return Vec::new(); + } + let opts = RtOptions::new(width.max(1) as usize) + .initial_indent(self.initial_prefix.clone()) + .subsequent_indent(self.subsequent_prefix.clone()); + let wrapped = word_wrap_lines(&self.text, opts); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out + } + + fn desired_height(&self, width: u16) -> u16 { + self.display_lines(width).len() as u16 + } +} + +fn truncate_exec_snippet(full_cmd: &str) -> String { + let mut snippet = match full_cmd.split_once('\n') { + Some((first, _)) => format!("{first} ..."), + None => full_cmd.to_string(), + }; + snippet = truncate_text(&snippet, 80); + snippet +} + +fn exec_snippet(command: &[String]) -> String { + let full_cmd = strip_bash_lc_and_escape(command); + truncate_exec_snippet(&full_cmd) +} + +pub fn new_approval_decision_cell( + command: Vec, + decision: codex_core::protocol::ReviewDecision, +) -> Box { + use codex_core::protocol::ReviewDecision::*; + + let (symbol, summary): (Span<'static>, Vec>) = match decision { + Approved => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " this time".bold(), + ], + ) + } + ApprovedExecpolicyAmendment { .. } => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " and applied the execpolicy amendment".bold(), + ], + ) + } + ApprovedForSession => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " every time this session".bold(), + ], + ) + } + Denied => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✗ ".red(), + vec![ + "You ".into(), + "did not approve".bold(), + " codex to run ".into(), + snippet, + ], + ) + } + Abort => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✗ ".red(), + vec![ + "You ".into(), + "canceled".bold(), + " the request to run ".into(), + snippet, + ], + ) + } + }; + + Box::new(PrefixedWrappedHistoryCell::new( + Line::from(summary), + symbol, + " ", + )) +} + +/// 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 { + changes: HashMap, + cwd: PathBuf, +} + +impl HistoryCell for PatchHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + create_diff_summary(&self.changes, &self.cwd, width as usize) + } +} + +#[derive(Debug)] +struct CompletedMcpToolCallWithImageOutput { + _image: DynamicImage, +} +impl HistoryCell for CompletedMcpToolCallWithImageOutput { + fn display_lines(&self, _width: u16) -> Vec> { + vec!["tool result (image output)".into()] + } +} + +pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value + +pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { + if width < 4 { + return None; + } + let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width); + Some(inner_width) +} + +/// Render `lines` inside a border sized to the widest span in the content. +pub(crate) fn with_border(lines: Vec>) -> Vec> { + with_border_internal(lines, None) +} + +/// Render `lines` inside a border whose inner width is at least `inner_width`. +/// +/// This is useful when callers have already clamped their content to a +/// specific width and want the border math centralized here instead of +/// duplicating padding logic in the TUI widgets themselves. +pub(crate) fn with_border_with_inner_width( + lines: Vec>, + inner_width: usize, +) -> Vec> { + with_border_internal(lines, Some(inner_width)) +} + +fn with_border_internal( + lines: Vec>, + forced_inner_width: Option, +) -> Vec> { + let max_line_width = lines + .iter() + .map(|line| { + line.iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum::() + }) + .max() + .unwrap_or(0); + let content_width = forced_inner_width + .unwrap_or(max_line_width) + .max(max_line_width); + + let mut out = Vec::with_capacity(lines.len() + 2); + let border_inner_width = content_width + 2; + out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into()); + + for line in lines.into_iter() { + let used_width: usize = line + .iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum(); + let span_count = line.spans.len(); + let mut spans: Vec> = Vec::with_capacity(span_count + 4); + spans.push(Span::from("│ ").dim()); + spans.extend(line.into_iter()); + if used_width < content_width { + spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); + } + spans.push(Span::from(" │").dim()); + out.push(Line::from(spans)); + } + + out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into()); + + out +} + +/// Return the emoji followed by a hair space (U+200A). +/// Using only the hair space avoids excessive padding after the emoji while +/// still providing a small visual gap across terminals. +pub(crate) fn padded_emoji(emoji: &str) -> String { + format!("{emoji}\u{200A}") +} + +#[derive(Debug)] +struct TooltipHistoryCell { + tip: &'static str, +} + +impl TooltipHistoryCell { + fn new(tip: &'static str) -> Self { + Self { tip } + } +} + +impl HistoryCell for TooltipHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let indent = " "; + let indent_width = UnicodeWidthStr::width(indent); + let wrap_width = usize::from(width.max(1)) + .saturating_sub(indent_width) + .max(1); + let mut lines: Vec> = Vec::new(); + append_markdown( + &format!("**Tip:** {}", self.tip), + Some(wrap_width), + &mut lines, + ); + + prefix_lines(lines, indent.into(), indent.into()) + } +} + +#[derive(Debug)] +pub struct SessionInfoCell(CompositeHistoryCell); + +impl HistoryCell for SessionInfoCell { + fn display_lines(&self, width: u16) -> Vec> { + self.0.display_lines(width) + } + + fn desired_height(&self, width: u16) -> u16 { + self.0.desired_height(width) + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.0.transcript_lines(width) + } +} + +pub(crate) fn new_session_info( + config: &Config, + requested_model: &str, + event: SessionConfiguredEvent, + is_first_event: bool, +) -> SessionInfoCell { + let SessionConfiguredEvent { + model, + reasoning_effort, + .. + } = event; + // Header box rendered as history (so it appears at the very top) + let header = SessionHeaderHistoryCell::new( + model.clone(), + reasoning_effort, + config.cwd.clone(), + CODEX_CLI_VERSION, + ); + let mut parts: Vec> = vec![Box::new(header)]; + + if is_first_event { + // Help lines below the header (new copy and list) + let help_lines: Vec> = vec![ + " To get started, describe a task or try one of these commands:" + .dim() + .into(), + Line::from(""), + Line::from(vec![ + " ".into(), + "/init".into(), + " - create an AGENTS.md file with instructions for Codex".dim(), + ]), + Line::from(vec![ + " ".into(), + "/status".into(), + " - show current session configuration".dim(), + ]), + Line::from(vec![ + " ".into(), + "/approvals".into(), + " - choose what Codex can do without approval".dim(), + ]), + Line::from(vec![ + " ".into(), + "/model".into(), + " - choose what model and reasoning effort to use".dim(), + ]), + Line::from(vec![ + " ".into(), + "/review".into(), + " - review any changes and find issues".dim(), + ]), + ]; + + parts.push(Box::new(PlainHistoryCell { lines: help_lines })); + } else { + if config.show_tooltips + && let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new) + { + parts.push(Box::new(tooltips)); + } + if requested_model != model { + let lines = vec![ + "model changed:".magenta().bold().into(), + format!("requested: {requested_model}").into(), + format!("used: {model}").into(), + ]; + parts.push(Box::new(PlainHistoryCell { lines })); + } + } + + SessionInfoCell(CompositeHistoryCell { parts }) +} + +pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { + UserHistoryCell { message } +} + +#[derive(Debug)] +struct SessionHeaderHistoryCell { + version: &'static str, + model: String, + reasoning_effort: Option, + directory: PathBuf, +} + +impl SessionHeaderHistoryCell { + fn new( + model: String, + reasoning_effort: Option, + directory: PathBuf, + version: &'static str, + ) -> Self { + Self { + version, + model, + reasoning_effort, + directory, + } + } + + fn format_directory(&self, max_width: Option) -> String { + Self::format_directory_inner(&self.directory, max_width) + } + + fn format_directory_inner(directory: &Path, max_width: Option) -> String { + let formatted = if let Some(rel) = relativize_to_home(directory) { + if rel.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) + } + } else { + directory.display().to_string() + }; + + if let Some(max_width) = max_width { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(formatted.as_str()) > max_width { + return crate::text_formatting::center_truncate_path(&formatted, max_width); + } + } + + formatted + } + + fn reasoning_label(&self) -> Option<&'static str> { + self.reasoning_effort.map(|effort| match effort { + ReasoningEffortConfig::Minimal => "minimal", + ReasoningEffortConfig::Low => "low", + ReasoningEffortConfig::Medium => "medium", + ReasoningEffortConfig::High => "high", + ReasoningEffortConfig::XHigh => "xhigh", + ReasoningEffortConfig::None => "none", + }) + } +} + +impl HistoryCell for SessionHeaderHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else { + return Vec::new(); + }; + + let make_row = |spans: Vec>| Line::from(spans); + + // Title line rendered inside the box: ">_ OpenAI Codex (vX)" + let title_spans: Vec> = vec![ + Span::from(">_ ").dim(), + Span::from("OpenAI Codex").bold(), + Span::from(" ").dim(), + Span::from(format!("(v{})", self.version)).dim(), + ]; + + const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; + const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; + const DIR_LABEL: &str = "directory:"; + let label_width = DIR_LABEL.len(); + let model_label = format!( + "{model_label:> = vec![ + Span::from(format!("{model_label} ")).dim(), + Span::from(self.model.clone()), + ]; + if let Some(reasoning) = reasoning_label { + model_spans.push(Span::from(" ")); + model_spans.push(Span::from(reasoning)); + } + model_spans.push(" ".dim()); + model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); + model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); + + let dir_label = format!("{DIR_LABEL:>, +} + +impl CompositeHistoryCell { + pub(crate) fn new(parts: Vec>) -> Self { + Self { parts } + } +} + +impl HistoryCell for CompositeHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.display_lines(width); + if !lines.is_empty() { + if !first { + out.push(Line::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } +} + +#[derive(Debug)] +pub(crate) struct McpToolCallCell { + call_id: String, + invocation: McpInvocation, + start_time: Instant, + duration: Option, + result: Option>, + animations_enabled: bool, +} + +impl McpToolCallCell { + pub(crate) fn new( + call_id: String, + invocation: McpInvocation, + animations_enabled: bool, + ) -> Self { + Self { + call_id, + invocation, + start_time: Instant::now(), + duration: None, + result: None, + animations_enabled, + } + } + + pub(crate) fn call_id(&self) -> &str { + &self.call_id + } + + pub(crate) fn complete( + &mut self, + duration: Duration, + result: Result, + ) -> Option> { + let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) + .map(|cell| Box::new(cell) as Box); + self.duration = Some(duration); + self.result = Some(result); + image_cell + } + + fn success(&self) -> Option { + match self.result.as_ref() { + Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)), + Some(Err(_)) => Some(false), + None => None, + } + } + + pub(crate) fn mark_failed(&mut self) { + let elapsed = self.start_time.elapsed(); + self.duration = Some(elapsed); + self.result = Some(Err("interrupted".to_string())); + } + + fn render_content_block(block: &mcp_types::ContentBlock, width: usize) -> String { + match block { + mcp_types::ContentBlock::TextContent(text) => { + format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width) + } + mcp_types::ContentBlock::ImageContent(_) => "".to_string(), + mcp_types::ContentBlock::AudioContent(_) => "