From 1fc3413a46f8b07e2175cbee2bba3e9585f37ccf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 26 Sep 2025 15:49:08 +0200 Subject: [PATCH 01/31] ref: state - 2 (#4229) Extracting tasks in a module and start abstraction behind a Trait (more to come on this but each task will be tackled in a dedicated PR) The goal was to drop the ActiveTask and to have a (potentially) set of tasks during each turn --- codex-rs/Cargo.lock | 2 + codex-rs/Cargo.toml | 1 + codex-rs/core/Cargo.toml | 2 + codex-rs/core/src/codex.rs | 405 ++++++++++++----------- codex-rs/core/src/codex/compact.rs | 34 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/state/mod.rs | 3 +- codex-rs/core/src/state/session.rs | 2 - codex-rs/core/src/state/turn.rs | 59 +++- codex-rs/core/src/tasks/compact.rs | 31 ++ codex-rs/core/src/tasks/mod.rs | 166 ++++++++++ codex-rs/core/src/tasks/regular.rs | 32 ++ codex-rs/core/src/tasks/review.rs | 37 +++ codex-rs/core/tests/suite/abort_tasks.rs | 66 ++++ codex-rs/core/tests/suite/mod.rs | 2 + 15 files changed, 617 insertions(+), 226 deletions(-) create mode 100644 codex-rs/core/src/tasks/compact.rs create mode 100644 codex-rs/core/src/tasks/mod.rs create mode 100644 codex-rs/core/src/tasks/regular.rs create mode 100644 codex-rs/core/src/tasks/review.rs create mode 100644 codex-rs/core/tests/suite/abort_tasks.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8b71f139b6..a54222a835 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -673,6 +673,7 @@ dependencies = [ "askama", "assert_cmd", "async-channel", + "async-trait", "base64", "bytes", "chrono", @@ -685,6 +686,7 @@ dependencies = [ "env-flags", "eventsource-stream", "futures", + "indexmap 2.10.0", "landlock", "libc", "maplit", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 7b4db5fcc5..237f5ea05c 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -85,6 +85,7 @@ icu_decimal = "2.0.0" icu_locale_core = "2.0.0" ignore = "0.4.23" image = { version = "^0.25.8", default-features = false } +indexmap = "2.6.0" insta = "1.43.2" itertools = "0.14.0" landlock = "0.4.1" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d9ded08283..a1e7876a26 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -15,6 +15,7 @@ workspace = true anyhow = { workspace = true } askama = { workspace = true } async-channel = { workspace = true } +async-trait = { workspace = true } base64 = { workspace = true } bytes = { workspace = true } chrono = { workspace = true, features = ["serde"] } @@ -26,6 +27,7 @@ dirs = { workspace = true } env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } +indexmap = { workspace = true } libc = { workspace = true } mcp-types = { workspace = true } os_info = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 48aa3dbd7c..93850a6583 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -24,7 +24,6 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::TaskStartedEvent; use codex_protocol::protocol::TurnAbortReason; -use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnContextItem; use futures::prelude::*; use mcp_types::CallToolResult; @@ -34,7 +33,6 @@ use serde_json; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::oneshot; -use tokio::task::AbortHandle; use tracing::debug; use tracing::error; use tracing::info; @@ -107,7 +105,6 @@ use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; -use crate::protocol::TaskCompleteEvent; use crate::protocol::TokenCountEvent; use crate::protocol::TokenUsage; use crate::protocol::TurnDiffEvent; @@ -120,6 +117,9 @@ use crate::safety::assess_safety_for_untrusted_command; use crate::shell; use crate::state::ActiveTurn; use crate::state::SessionServices; +use crate::tasks::CompactTask; +use crate::tasks::RegularTask; +use crate::tasks::ReviewTask; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_instructions::UserInstructions; @@ -262,7 +262,7 @@ pub(crate) struct Session { conversation_id: ConversationId, tx_event: Sender, state: Mutex, - active_turn: Mutex>, + pub(crate) active_turn: Mutex>, services: SessionServices, next_internal_sub_id: AtomicU64, } @@ -495,38 +495,6 @@ impl Session { Ok((sess, turn_context)) } - pub async fn set_task(&self, task: AgentTask) { - let mut state = self.state.lock().await; - if let Some(current_task) = state.current_task.take() { - current_task.abort(TurnAbortReason::Replaced); - } - state.current_task = Some(task); - if let Some(current_task) = &state.current_task { - let mut active = self.active_turn.lock().await; - *active = Some(ActiveTurn { - sub_id: current_task.sub_id.clone(), - turn_state: std::sync::Arc::new(tokio::sync::Mutex::new( - crate::state::TurnState::default(), - )), - }); - } - } - - pub async fn remove_task(&self, sub_id: &str) { - let mut state = self.state.lock().await; - if let Some(task) = &state.current_task - && task.sub_id == sub_id - { - state.current_task.take(); - } - let mut active = self.active_turn.lock().await; - if let Some(at) = &*active - && at.sub_id == sub_id - { - *active = None; - } - } - fn next_internal_sub_id(&self) -> String { let id = self .next_internal_sub_id @@ -1015,26 +983,25 @@ impl Session { /// Returns the input if there was no task running to inject into pub async fn inject_input(&self, input: Vec) -> Result<(), Vec> { - let state = self.state.lock().await; - if state.current_task.is_some() { - let mut active = self.active_turn.lock().await; - if let Some(at) = active.as_mut() { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { let mut ts = at.turn_state.lock().await; ts.push_pending_input(input.into()); + Ok(()) } - Ok(()) - } else { - Err(input) + None => Err(input), } } pub async fn get_pending_input(&self) -> Vec { let mut active = self.active_turn.lock().await; - if let Some(at) = active.as_mut() { - let mut ts = at.turn_state.lock().await; - ts.take_pending_input() - } else { - Vec::with_capacity(0) + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.take_pending_input() + } + None => Vec::with_capacity(0), } } @@ -1050,29 +1017,20 @@ impl Session { .await } - pub async fn interrupt_task(&self) { + pub async fn interrupt_task(self: &Arc) { info!("interrupt received: abort current task, if any"); - let mut state = self.state.lock().await; - let mut active = self.active_turn.lock().await; - if let Some(at) = active.as_mut() { - let mut ts = at.turn_state.lock().await; - ts.clear_pending(); - } - if let Some(task) = state.current_task.take() { - task.abort(TurnAbortReason::Interrupted); - } + self.abort_all_tasks(TurnAbortReason::Interrupted).await; } fn interrupt_task_sync(&self) { - if let Ok(mut state) = self.state.try_lock() { - if let Ok(mut active) = self.active_turn.try_lock() - && let Some(at) = active.as_mut() - && let Ok(mut ts) = at.turn_state.try_lock() - { - ts.clear_pending(); - } - if let Some(task) = state.current_task.take() { - task.abort(TurnAbortReason::Interrupted); + if let Ok(mut active) = self.active_turn.try_lock() + && let Some(at) = active.as_mut() + { + at.try_clear_pending_sync(); + let tasks = at.drain_tasks(); + *active = None; + for (_sub_id, task) in tasks { + task.handle.abort(); } } } @@ -1111,106 +1069,6 @@ pub(crate) struct ApplyPatchCommandContext { pub(crate) changes: HashMap, } -#[derive(Clone, Debug, Eq, PartialEq)] -enum AgentTaskKind { - Regular, - Review, - Compact, -} - -/// A series of Turns in response to user input. -pub(crate) struct AgentTask { - sess: Arc, - sub_id: String, - handle: AbortHandle, - kind: AgentTaskKind, -} - -impl AgentTask { - fn spawn( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, - ) -> Self { - let handle = { - let sess = sess.clone(); - let sub_id = sub_id.clone(); - let tc = Arc::clone(&turn_context); - tokio::spawn(async move { run_task(sess, tc, sub_id, input).await }).abort_handle() - }; - Self { - sess, - sub_id, - handle, - kind: AgentTaskKind::Regular, - } - } - - fn review( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, - ) -> Self { - let handle = { - let sess = sess.clone(); - let sub_id = sub_id.clone(); - let tc = Arc::clone(&turn_context); - tokio::spawn(async move { run_task(sess, tc, sub_id, input).await }).abort_handle() - }; - Self { - sess, - sub_id, - handle, - kind: AgentTaskKind::Review, - } - } - - fn compact( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, - ) -> Self { - let handle = { - let sess = sess.clone(); - let sub_id = sub_id.clone(); - let tc = Arc::clone(&turn_context); - tokio::spawn(async move { compact::run_compact_task(sess, tc, sub_id, input).await }) - .abort_handle() - }; - Self { - sess, - sub_id, - handle, - kind: AgentTaskKind::Compact, - } - } - - fn abort(self, reason: TurnAbortReason) { - // TOCTOU? - if !self.handle.is_finished() { - self.handle.abort(); - let sub_id = self.sub_id.clone(); - let is_review = self.kind == AgentTaskKind::Review; - let sess = self.sess; - let event = Event { - id: sub_id.clone(), - msg: EventMsg::TurnAborted(TurnAbortedEvent { reason }), - }; - tokio::spawn(async move { - if is_review { - exit_review_mode(sess.clone(), sub_id.clone(), None).await; - } - // Ensure active turn state is cleared when a task is aborted. - sess.remove_task(&sub_id).await; - sess.send_event(event).await; - }); - } - } -} - async fn submission_loop( sess: Arc, turn_context: TurnContext, @@ -1318,9 +1176,8 @@ async fn submission_loop( // attempt to inject input into current task if let Err(items) = sess.inject_input(items).await { // no current task, spawn a new one - let task = - AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items); - sess.set_task(task).await; + sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask) + .await; } } Op::UserTurn { @@ -1396,10 +1253,9 @@ async fn submission_loop( // Install the new persistent context for subsequent tasks/turns. turn_context = Arc::new(fresh_turn_context); - // no current task, spawn a new one with the per‑turn context - let task = - AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items); - sess.set_task(task).await; + // no current task, spawn a new one with the per-turn context + sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask) + .await; } } Op::ExecApproval { id, decision } => match decision { @@ -1497,16 +1353,12 @@ async fn submission_loop( }]) .await { - compact::spawn_compact_task( - sess.clone(), - Arc::clone(&turn_context), - sub.id, - items, - ) - .await; + sess.spawn_task(Arc::clone(&turn_context), sub.id, items, CompactTask) + .await; } } Op::Shutdown => { + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; info!("Shutting down Codex instance"); // Gracefully flush and shutdown rollout recorder on session end so tests @@ -1648,8 +1500,7 @@ async fn spawn_review_thread( // Clone sub_id for the upcoming announcement before moving it into the task. let sub_id_for_event = sub_id.clone(); - let task = AgentTask::review(sess.clone(), tc.clone(), sub_id, input); - sess.set_task(task).await; + sess.spawn_task(tc.clone(), sub_id, input, ReviewTask).await; // Announce entering review mode so UIs can switch modes. sess.send_event(Event { @@ -1676,14 +1527,14 @@ async fn spawn_review_thread( /// Review mode: when `turn_context.is_review_mode` is true, the turn runs in an /// isolated in-memory thread without the parent session's prior history or /// user_instructions. Emits ExitedReviewMode upon final review message. -async fn run_task( +pub(crate) async fn run_task( sess: Arc, turn_context: Arc, sub_id: String, input: Vec, -) { +) -> Option { if input.is_empty() { - return; + return None; } let event = Event { id: sub_id.clone(), @@ -1955,12 +1806,7 @@ async fn run_task( .await; } - sess.remove_task(&sub_id).await; - let event = Event { - id: sub_id, - msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }), - }; - sess.send_event(event).await; + last_agent_message } /// Parse the review output; when not valid JSON, build a structured @@ -3219,7 +3065,7 @@ fn convert_call_tool_result_to_function_call_output_payload( /// Emits an ExitedReviewMode Event with optional ReviewOutput, /// and records a developer message with the review output. -async fn exit_review_mode( +pub(crate) async fn exit_review_mode( session: Arc, task_sub_id: String, review_output: Option, @@ -3283,6 +3129,9 @@ mod tests { use crate::protocol::CompactedItem; use crate::protocol::InitialHistory; use crate::protocol::ResumedHistory; + use crate::state::TaskKind; + use crate::tasks::SessionTask; + use crate::tasks::SessionTaskContext; use codex_protocol::models::ContentItem; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -3292,6 +3141,8 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; use std::time::Duration as StdDuration; + use tokio::time::Duration; + use tokio::time::sleep; #[test] fn reconstruct_history_matches_live_compactions() { @@ -3577,6 +3428,174 @@ mod tests { (session, turn_context) } + // Like make_session_and_context, but returns Arc and the event receiver + // so tests can assert on emitted events. + fn make_session_and_context_with_rx() -> ( + Arc, + Arc, + async_channel::Receiver, + ) { + let (tx_event, rx_event) = async_channel::unbounded(); + let codex_home = tempfile::tempdir().expect("create temp dir"); + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + let config = Arc::new(config); + let conversation_id = ConversationId::default(); + let client = ModelClient::new( + config.clone(), + None, + config.model_provider.clone(), + config.model_reasoning_effort, + config.model_reasoning_summary, + conversation_id, + ); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_family: &config.model_family, + include_plan_tool: config.include_plan_tool, + include_apply_patch_tool: config.include_apply_patch_tool, + include_web_search_request: config.tools_web_search_request, + use_streamable_shell_tool: config.use_experimental_streamable_shell_tool, + include_view_image_tool: config.include_view_image_tool, + experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, + }); + let turn_context = Arc::new(TurnContext { + client, + cwd: config.cwd.clone(), + base_instructions: config.base_instructions.clone(), + user_instructions: config.user_instructions.clone(), + approval_policy: config.approval_policy, + sandbox_policy: config.sandbox_policy.clone(), + shell_environment_policy: config.shell_environment_policy.clone(), + tools_config, + is_review_mode: false, + final_output_json_schema: None, + }); + let services = SessionServices { + mcp_connection_manager: McpConnectionManager::default(), + session_manager: ExecSessionManager::default(), + unified_exec_manager: UnifiedExecSessionManager::default(), + notifier: UserNotifier::default(), + rollout: Mutex::new(None), + codex_linux_sandbox_exe: None, + user_shell: shell::Shell::Unknown, + show_raw_agent_reasoning: config.show_raw_agent_reasoning, + }; + let session = Arc::new(Session { + conversation_id, + tx_event, + state: Mutex::new(SessionState::new()), + active_turn: Mutex::new(None), + services, + next_internal_sub_id: AtomicU64::new(0), + }); + (session, turn_context, rx_event) + } + + #[derive(Clone, Copy)] + struct NeverEndingTask(TaskKind); + + #[async_trait::async_trait] + impl SessionTask for NeverEndingTask { + fn kind(&self) -> TaskKind { + self.0 + } + + async fn run( + self: Arc, + _session: Arc, + _ctx: Arc, + _sub_id: String, + _input: Vec, + ) -> Option { + loop { + sleep(Duration::from_secs(60)).await; + } + } + + async fn abort(&self, session: Arc, sub_id: &str) { + if let TaskKind::Review = self.0 { + exit_review_mode(session.clone_session(), sub_id.to_string(), None).await; + } + } + } + + #[tokio::test] + async fn abort_regular_task_emits_turn_aborted_only() { + let (sess, tc, rx) = make_session_and_context_with_rx(); + let sub_id = "sub-regular".to_string(); + let input = vec![InputItem::Text { + text: "hello".to_string(), + }]; + sess.spawn_task( + Arc::clone(&tc), + sub_id.clone(), + input, + NeverEndingTask(TaskKind::Regular), + ) + .await; + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + + let evt = rx.recv().await.expect("event"); + match evt.msg { + EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), + other => panic!("unexpected event: {other:?}"), + } + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn abort_review_task_emits_exited_then_aborted_and_records_history() { + let (sess, tc, rx) = make_session_and_context_with_rx(); + let sub_id = "sub-review".to_string(); + let input = vec![InputItem::Text { + text: "start review".to_string(), + }]; + sess.spawn_task( + Arc::clone(&tc), + sub_id.clone(), + input, + NeverEndingTask(TaskKind::Review), + ) + .await; + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + + let first = rx.recv().await.expect("first event"); + match first.msg { + EventMsg::ExitedReviewMode(ev) => assert!(ev.review_output.is_none()), + other => panic!("unexpected first event: {other:?}"), + } + let second = rx.recv().await.expect("second event"); + match second.msg { + EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), + other => panic!("unexpected second event: {other:?}"), + } + + let history = sess.history_snapshot().await; + let found = history.iter().any(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + content.iter().any(|ci| match ci { + ContentItem::InputText { text } => { + text.contains("") + && text.contains("review") + && text.contains("interrupted") + } + _ => false, + }) + } + _ => false, + }); + assert!( + found, + "synthetic review interruption not recorded in history" + ); + } + fn sample_rollout( session: &Session, turn_context: &TurnContext, diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 4facd45d5d..8689bd020c 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use super::AgentTask; use super::Session; use super::TurnContext; use super::get_last_assistant_message_from_turn; @@ -15,7 +14,6 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::InputItem; use crate::protocol::InputMessageKind; -use crate::protocol::TaskCompleteEvent; use crate::protocol::TaskStartedEvent; use crate::protocol::TurnContextItem; use crate::truncate::truncate_middle; @@ -37,17 +35,7 @@ struct HistoryBridgeTemplate<'a> { summary_text: &'a str, } -pub(super) async fn spawn_compact_task( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, -) { - let task = AgentTask::compact(sess.clone(), turn_context, sub_id, input); - sess.set_task(task).await; -} - -pub(super) async fn run_inline_auto_compact_task( +pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, ) { @@ -55,15 +43,15 @@ pub(super) async fn run_inline_auto_compact_task( let input = vec![InputItem::Text { text: SUMMARIZATION_PROMPT.to_string(), }]; - run_compact_task_inner(sess, turn_context, sub_id, input, false).await; + run_compact_task_inner(sess, turn_context, sub_id, input).await; } -pub(super) async fn run_compact_task( +pub(crate) async fn run_compact_task( sess: Arc, turn_context: Arc, sub_id: String, input: Vec, -) { +) -> Option { let start_event = Event { id: sub_id.clone(), msg: EventMsg::TaskStarted(TaskStartedEvent { @@ -71,14 +59,8 @@ pub(super) async fn run_compact_task( }), }; sess.send_event(start_event).await; - run_compact_task_inner(sess.clone(), turn_context, sub_id.clone(), input, true).await; - let event = Event { - id: sub_id, - msg: EventMsg::TaskComplete(TaskCompleteEvent { - last_agent_message: None, - }), - }; - sess.send_event(event).await; + run_compact_task_inner(sess.clone(), turn_context, sub_id.clone(), input).await; + None } async fn run_compact_task_inner( @@ -86,7 +68,6 @@ async fn run_compact_task_inner( turn_context: Arc, sub_id: String, input: Vec, - remove_task_on_completion: bool, ) { let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let turn_input = sess @@ -148,9 +129,6 @@ async fn run_compact_task_inner( } } - if remove_task_on_completion { - sess.remove_task(&sub_id).await; - } let history_snapshot = sess.history_snapshot().await; let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default(); let user_messages = collect_user_messages(&history_snapshot); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 36287c1af1..ad040ec855 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -77,6 +77,7 @@ pub use rollout::list::ConversationsPage; pub use rollout::list::Cursor; mod function_tool; mod state; +mod tasks; mod user_notification; pub mod util; diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index 927f598146..642433a786 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -5,4 +5,5 @@ mod turn; pub(crate) use service::SessionServices; pub(crate) use session::SessionState; pub(crate) use turn::ActiveTurn; -pub(crate) use turn::TurnState; +pub(crate) use turn::RunningTask; +pub(crate) use turn::TaskKind; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index f8afbdeccf..ee0c5fc976 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -4,7 +4,6 @@ use std::collections::HashSet; use codex_protocol::models::ResponseItem; -use crate::codex::AgentTask; use crate::conversation_history::ConversationHistory; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; @@ -14,7 +13,6 @@ use crate::protocol::TokenUsageInfo; #[derive(Default)] pub(crate) struct SessionState { pub(crate) approved_commands: HashSet>, - pub(crate) current_task: Option, pub(crate) history: ConversationHistory, pub(crate) token_info: Option, pub(crate) latest_rate_limits: Option, diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index b49c86b546..f715d5481e 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -1,21 +1,61 @@ //! Turn-scoped state and active turn metadata scaffolding. +use indexmap::IndexMap; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::task::AbortHandle; use codex_protocol::models::ResponseInputItem; use tokio::sync::oneshot; use crate::protocol::ReviewDecision; +use crate::tasks::SessionTask; /// Metadata about the currently running turn. -#[derive(Default)] pub(crate) struct ActiveTurn { - pub(crate) sub_id: String, + pub(crate) tasks: IndexMap, pub(crate) turn_state: Arc>, } +impl Default for ActiveTurn { + fn default() -> Self { + Self { + tasks: IndexMap::new(), + turn_state: Arc::new(Mutex::new(TurnState::default())), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TaskKind { + Regular, + Review, + Compact, +} + +#[derive(Clone)] +pub(crate) struct RunningTask { + pub(crate) handle: AbortHandle, + pub(crate) kind: TaskKind, + pub(crate) task: Arc, +} + +impl ActiveTurn { + pub(crate) fn add_task(&mut self, sub_id: String, task: RunningTask) { + self.tasks.insert(sub_id, task); + } + + pub(crate) fn remove_task(&mut self, sub_id: &str) -> bool { + self.tasks.swap_remove(sub_id); + self.tasks.is_empty() + } + + pub(crate) fn drain_tasks(&mut self) -> IndexMap { + std::mem::take(&mut self.tasks) + } +} + /// Mutable state for a single turn. #[derive(Default)] pub(crate) struct TurnState { @@ -58,3 +98,18 @@ impl TurnState { } } } + +impl ActiveTurn { + /// Clear any pending approvals and input buffered for the current turn. + pub(crate) async fn clear_pending(&self) { + let mut ts = self.turn_state.lock().await; + ts.clear_pending(); + } + + /// Best-effort, non-blocking variant for synchronous contexts (Drop/interrupt). + pub(crate) fn try_clear_pending_sync(&self) { + if let Ok(mut ts) = self.turn_state.try_lock() { + ts.clear_pending(); + } + } +} diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs new file mode 100644 index 0000000000..823febfc94 --- /dev/null +++ b/codex-rs/core/src/tasks/compact.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::codex::TurnContext; +use crate::codex::compact; +use crate::protocol::InputItem; +use crate::state::TaskKind; + +use super::SessionTask; +use super::SessionTaskContext; + +#[derive(Clone, Copy, Default)] +pub(crate) struct CompactTask; + +#[async_trait] +impl SessionTask for CompactTask { + fn kind(&self) -> TaskKind { + TaskKind::Compact + } + + async fn run( + self: Arc, + session: Arc, + ctx: Arc, + sub_id: String, + input: Vec, + ) -> Option { + compact::run_compact_task(session.clone_session(), ctx, sub_id, input).await + } +} diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs new file mode 100644 index 0000000000..464c1e633a --- /dev/null +++ b/codex-rs/core/src/tasks/mod.rs @@ -0,0 +1,166 @@ +mod compact; +mod regular; +mod review; + +use std::sync::Arc; + +use async_trait::async_trait; +use tracing::trace; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::protocol::Event; +use crate::protocol::EventMsg; +use crate::protocol::InputItem; +use crate::protocol::TaskCompleteEvent; +use crate::protocol::TurnAbortReason; +use crate::protocol::TurnAbortedEvent; +use crate::state::ActiveTurn; +use crate::state::RunningTask; +use crate::state::TaskKind; + +pub(crate) use compact::CompactTask; +pub(crate) use regular::RegularTask; +pub(crate) use review::ReviewTask; + +/// Thin wrapper that exposes the parts of [`Session`] task runners need. +#[derive(Clone)] +pub(crate) struct SessionTaskContext { + session: Arc, +} + +impl SessionTaskContext { + pub(crate) fn new(session: Arc) -> Self { + Self { session } + } + + pub(crate) fn clone_session(&self) -> Arc { + Arc::clone(&self.session) + } +} + +#[async_trait] +pub(crate) trait SessionTask: Send + Sync + 'static { + fn kind(&self) -> TaskKind; + + async fn run( + self: Arc, + session: Arc, + ctx: Arc, + sub_id: String, + input: Vec, + ) -> Option; + + async fn abort(&self, session: Arc, sub_id: &str) { + let _ = (session, sub_id); + } +} + +impl Session { + pub async fn spawn_task( + self: &Arc, + turn_context: Arc, + sub_id: String, + input: Vec, + task: T, + ) { + self.abort_all_tasks(TurnAbortReason::Replaced).await; + + let task: Arc = Arc::new(task); + let task_kind = task.kind(); + + let handle = { + let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); + let ctx = Arc::clone(&turn_context); + let task_for_run = Arc::clone(&task); + let sub_clone = sub_id.clone(); + tokio::spawn(async move { + let last_agent_message = task_for_run + .run(Arc::clone(&session_ctx), ctx, sub_clone.clone(), input) + .await; + // Emit completion uniformly from spawn site so all tasks share the same lifecycle. + let sess = session_ctx.clone_session(); + sess.on_task_finished(sub_clone, last_agent_message).await; + }) + .abort_handle() + }; + + let running_task = RunningTask { + handle, + kind: task_kind, + task, + }; + self.register_new_active_task(sub_id, running_task).await; + } + + pub async fn abort_all_tasks(self: &Arc, reason: TurnAbortReason) { + for (sub_id, task) in self.take_all_running_tasks().await { + self.handle_task_abort(sub_id, task, reason.clone()).await; + } + } + + pub async fn on_task_finished( + self: &Arc, + sub_id: String, + last_agent_message: Option, + ) { + let mut active = self.active_turn.lock().await; + if let Some(at) = active.as_mut() + && at.remove_task(&sub_id) + { + *active = None; + } + drop(active); + let event = Event { + id: sub_id, + msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }), + }; + self.send_event(event).await; + } + + async fn register_new_active_task(&self, sub_id: String, task: RunningTask) { + let mut active = self.active_turn.lock().await; + let mut turn = ActiveTurn::default(); + turn.add_task(sub_id, task); + *active = Some(turn); + } + + async fn take_all_running_tasks(&self) -> Vec<(String, RunningTask)> { + let mut active = self.active_turn.lock().await; + match active.take() { + Some(mut at) => { + at.clear_pending().await; + let tasks = at.drain_tasks(); + tasks.into_iter().collect() + } + None => Vec::new(), + } + } + + async fn handle_task_abort( + self: &Arc, + sub_id: String, + task: RunningTask, + reason: TurnAbortReason, + ) { + if task.handle.is_finished() { + return; + } + + trace!(task_kind = ?task.kind, sub_id, "aborting running task"); + let session_task = task.task; + let handle = task.handle; + handle.abort(); + let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); + session_task.abort(session_ctx, &sub_id).await; + + let event = Event { + id: sub_id.clone(), + msg: EventMsg::TurnAborted(TurnAbortedEvent { reason }), + }; + self.send_event(event).await; + } +} + +#[cfg(test)] +mod tests {} diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs new file mode 100644 index 0000000000..9d24099746 --- /dev/null +++ b/codex-rs/core/src/tasks/regular.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::codex::TurnContext; +use crate::codex::run_task; +use crate::protocol::InputItem; +use crate::state::TaskKind; + +use super::SessionTask; +use super::SessionTaskContext; + +#[derive(Clone, Copy, Default)] +pub(crate) struct RegularTask; + +#[async_trait] +impl SessionTask for RegularTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + async fn run( + self: Arc, + session: Arc, + ctx: Arc, + sub_id: String, + input: Vec, + ) -> Option { + let sess = session.clone_session(); + run_task(sess, ctx, sub_id, input).await + } +} diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs new file mode 100644 index 0000000000..047a2f40e2 --- /dev/null +++ b/codex-rs/core/src/tasks/review.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::codex::TurnContext; +use crate::codex::exit_review_mode; +use crate::codex::run_task; +use crate::protocol::InputItem; +use crate::state::TaskKind; + +use super::SessionTask; +use super::SessionTaskContext; + +#[derive(Clone, Copy, Default)] +pub(crate) struct ReviewTask; + +#[async_trait] +impl SessionTask for ReviewTask { + fn kind(&self) -> TaskKind { + TaskKind::Review + } + + async fn run( + self: Arc, + session: Arc, + ctx: Arc, + sub_id: String, + input: Vec, + ) -> Option { + let sess = session.clone_session(); + run_task(sess, ctx, sub_id, input).await + } + + async fn abort(&self, session: Arc, sub_id: &str) { + exit_review_mode(session.clone_session(), sub_id.to_string(), None).await; + } +} diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs new file mode 100644 index 0000000000..2fa3d4df6b --- /dev/null +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -0,0 +1,66 @@ +use std::time::Duration; + +use codex_core::protocol::EventMsg; +use codex_core::protocol::InputItem; +use codex_core::protocol::Op; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_with_timeout; +use serde_json::json; +use wiremock::matchers::body_string_contains; + +/// Integration test: spawn a long‑running shell tool via a mocked Responses SSE +/// function call, then interrupt the session and expect TurnAborted. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interrupt_long_running_tool_emits_turn_aborted() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "sleep 60".to_string(), + ]; + + let args = json!({ + "command": command, + "timeout_ms": 60_000 + }) + .to_string(); + let body = sse(vec![ev_function_call("call_sleep", "shell", &args)]); + + let server = start_mock_server().await; + mount_sse_once(&server, body_string_contains("start sleep"), body).await; + + let codex = test_codex().build(&server).await.unwrap().codex; + + let wait_timeout = Duration::from_secs(5); + + // Kick off a turn that triggers the function call. + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "start sleep".into(), + }], + }) + .await + .unwrap(); + + // Wait until the exec begins to avoid a race, then interrupt. + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::ExecCommandBegin(_)), + wait_timeout, + ) + .await; + + codex.submit(Op::Interrupt).await.unwrap(); + + // Expect TurnAborted soon after. + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TurnAborted(_)), + wait_timeout, + ) + .await; +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 2d91e330a8..0e4e725c36 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,5 +1,7 @@ // Aggregates all former standalone integration tests as modules. +#[cfg(not(target_os = "windows"))] +mod abort_tasks; mod cli_stream; mod client; mod compact; From 02609184bed60ad566b9c0d4d523f03ef6c2e750 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 26 Sep 2025 07:13:13 -0700 Subject: [PATCH 02/31] Refactor the footer logic to a new file (#4259) This will help us have more control over the footer --------- Co-authored-by: pakrym-oai --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 87 +--- codex-rs/tui/src/bottom_pane/footer.rs | 386 ++++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 1 + ...ooter__tests__footer_ctrl_c_quit_idle.snap | 7 + ...er__tests__footer_ctrl_c_quit_running.snap | 7 + ...oter__tests__footer_shortcuts_default.snap | 7 + ...tests__footer_shortcuts_shift_and_esc.snap | 7 + 7 files changed, 428 insertions(+), 74 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/footer.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7530794c9f..55762979ad 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,5 +1,4 @@ use codex_core::protocol::TokenUsageInfo; -use codex_protocol::num_format::format_si_suffix; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -25,6 +24,8 @@ 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::FooterProps; +use super::footer::render_footer; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use crate::bottom_pane::paste_burst::FlushResult; @@ -37,7 +38,6 @@ 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::key_hint; use crate::ui_consts::LIVE_PREFIX_COLS; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -1263,78 +1263,17 @@ impl WidgetRef for ChatComposer { } else { popup_rect }; - let mut hint: Vec> = if self.ctrl_c_quit_hint { - let ctrl_c_followup = if self.is_task_running { - " to interrupt" - } else { - " to quit" - }; - vec![ - " ".into(), - key_hint::ctrl('C'), - " again".into(), - ctrl_c_followup.into(), - ] - } else { - let newline_hint_key = if self.use_shift_enter_hint { - key_hint::shift('⏎') - } else { - key_hint::ctrl('J') - }; - vec![ - key_hint::plain('⏎'), - " send ".into(), - newline_hint_key, - " newline ".into(), - key_hint::ctrl('T'), - " transcript ".into(), - key_hint::ctrl('C'), - " quit".into(), - ] - }; - - if !self.ctrl_c_quit_hint && self.esc_backtrack_hint { - hint.push(" ".into()); - hint.push(key_hint::plain("Esc")); - hint.push(" edit prev".into()); - } - - // Append token/context usage info to the footer hints when available. - if let Some(token_usage_info) = &self.token_usage_info { - let token_usage = &token_usage_info.total_token_usage; - hint.push(" ".into()); - hint.push( - Span::from(format!( - "{} tokens used", - format_si_suffix(token_usage.blended_total()) - )) - .style(Style::default().add_modifier(Modifier::DIM)), - ); - let last_token_usage = &token_usage_info.last_token_usage; - if let Some(context_window) = token_usage_info.model_context_window { - let percent_remaining: u8 = if context_window > 0 { - last_token_usage.percent_of_context_window_remaining(context_window) - } else { - 100 - }; - let context_style = if percent_remaining < 20 { - Style::default().fg(Color::Yellow) - } else { - Style::default().add_modifier(Modifier::DIM) - }; - hint.push(" ".into()); - hint.push(Span::styled( - format!("{percent_remaining}% context left"), - context_style, - )); - } - } - - let hint = hint - .into_iter() - .map(|span| span.patch_style(Style::default().dim())) - .collect::>(); - Line::from(hint).render_ref(hint_rect, buf); + render_footer( + hint_rect, + buf, + FooterProps { + ctrl_c_quit_hint: self.ctrl_c_quit_hint, + is_task_running: self.is_task_running, + esc_backtrack_hint: self.esc_backtrack_hint, + use_shift_enter_hint: self.use_shift_enter_hint, + token_usage_info: self.token_usage_info.as_ref(), + }, + ); } } let border_style = if self.has_focus { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs new file mode 100644 index 0000000000..77e2c106e5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -0,0 +1,386 @@ +use codex_core::protocol::TokenUsageInfo; +use codex_protocol::num_format::format_si_suffix; +use crossterm::event::KeyCode; +use crossterm::event::KeyModifiers; +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; +use ratatui::text::Span; +use ratatui::widgets::WidgetRef; + +use crate::key_hint; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct FooterProps<'a> { + pub(crate) ctrl_c_quit_hint: bool, + pub(crate) is_task_running: bool, + pub(crate) esc_backtrack_hint: bool, + pub(crate) use_shift_enter_hint: bool, + pub(crate) token_usage_info: Option<&'a TokenUsageInfo>, +} + +#[derive(Clone, Copy, Debug)] +struct CtrlCReminderState { + pub(crate) is_task_running: bool, +} + +#[derive(Clone, Copy, Debug)] +struct ShortcutsState { + pub(crate) use_shift_enter_hint: bool, + pub(crate) esc_backtrack_hint: bool, +} + +#[derive(Clone, Copy, Debug)] +enum FooterContent { + Shortcuts(ShortcutsState), + CtrlCReminder(CtrlCReminderState), +} + +pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) { + let content = if props.ctrl_c_quit_hint { + FooterContent::CtrlCReminder(CtrlCReminderState { + is_task_running: props.is_task_running, + }) + } else { + FooterContent::Shortcuts(ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + }) + }; + + let mut spans = footer_spans(content); + if let Some(token_usage_info) = props.token_usage_info { + append_token_usage_spans(&mut spans, token_usage_info); + } + + let spans = spans + .into_iter() + .map(|span| span.patch_style(Style::default().dim())) + .collect::>(); + Line::from(spans).render_ref(area, buf); +} + +fn footer_spans(content: FooterContent) -> Vec> { + match content { + FooterContent::Shortcuts(state) => shortcuts_spans(state), + FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state), + } +} + +fn append_token_usage_spans(spans: &mut Vec>, token_usage_info: &TokenUsageInfo) { + let token_usage = &token_usage_info.total_token_usage; + spans.push(" ".into()); + spans.push( + Span::from(format!( + "{} tokens used", + format_si_suffix(token_usage.blended_total()) + )) + .style(Style::default().add_modifier(Modifier::DIM)), + ); + + let last_token_usage = &token_usage_info.last_token_usage; + if let Some(context_window) = token_usage_info.model_context_window { + let percent_remaining: u8 = if context_window > 0 { + last_token_usage.percent_of_context_window_remaining(context_window) + } else { + 100 + }; + + let context_style = if percent_remaining < 20 { + Style::default().fg(Color::Yellow) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + spans.push(" ".into()); + spans.push(Span::styled( + format!("{percent_remaining}% context left"), + context_style, + )); + } +} + +fn shortcuts_spans(state: ShortcutsState) -> Vec> { + let mut spans = Vec::new(); + for descriptor in SHORTCUTS { + if let Some(segment) = descriptor.footer_segment(state) { + if !segment.prefix.is_empty() { + spans.push(segment.prefix.into()); + } + spans.push(segment.binding.span()); + spans.push(segment.label.into()); + } + } + spans +} + +fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec> { + let followup = if state.is_task_running { + " to interrupt" + } else { + " to quit" + }; + vec![ + " ".into(), + key_hint::ctrl('C'), + " again".into(), + followup.into(), + ] +} + +#[derive(Clone, Copy, Debug)] +struct FooterSegment { + prefix: &'static str, + binding: ShortcutBinding, + label: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum ShortcutId { + Send, + InsertNewline, + ShowTranscript, + Quit, + EditPrevious, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ShortcutBinding { + code: KeyCode, + modifiers: KeyModifiers, + display: ShortcutDisplay, + condition: DisplayCondition, +} + +impl ShortcutBinding { + fn span(&self) -> Span<'static> { + self.display.into_span() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShortcutDisplay { + Plain(&'static str), + Ctrl(char), + Shift(char), +} + +impl ShortcutDisplay { + fn into_span(self) -> Span<'static> { + match self { + ShortcutDisplay::Plain(text) => key_hint::plain(text), + ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch), + ShortcutDisplay::Shift(ch) => key_hint::shift(ch), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DisplayCondition { + Always, + WhenShiftEnterHint, + WhenNotShiftEnterHint, +} + +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, + } + } +} + +struct ShortcutDescriptor { + id: ShortcutId, + bindings: &'static [ShortcutBinding], + footer_label: &'static str, + footer_prefix: &'static str, +} + +impl ShortcutDescriptor { + fn binding_for(&self, state: ShortcutsState) -> Option { + self.bindings + .iter() + .find(|binding| binding.condition.matches(state)) + .copied() + } + + fn should_show(&self, state: ShortcutsState) -> bool { + match self.id { + ShortcutId::EditPrevious => state.esc_backtrack_hint, + _ => true, + } + } + + fn footer_segment(&self, state: ShortcutsState) -> Option { + if !self.should_show(state) { + return None; + } + let binding = self.binding_for(state)?; + Some(FooterSegment { + prefix: self.footer_prefix, + binding, + label: self.footer_label, + }) + } +} + +const SHORTCUTS: &[ShortcutDescriptor] = &[ + ShortcutDescriptor { + id: ShortcutId::Send, + bindings: &[ShortcutBinding { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + display: ShortcutDisplay::Plain("⏎"), + condition: DisplayCondition::Always, + }], + footer_label: " send ", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::InsertNewline, + bindings: &[ + ShortcutBinding { + code: KeyCode::Enter, + modifiers: KeyModifiers::SHIFT, + display: ShortcutDisplay::Shift('⏎'), + condition: DisplayCondition::WhenShiftEnterHint, + }, + ShortcutBinding { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::CONTROL, + display: ShortcutDisplay::Ctrl('J'), + condition: DisplayCondition::WhenNotShiftEnterHint, + }, + ], + footer_label: " newline ", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::ShowTranscript, + bindings: &[ShortcutBinding { + code: KeyCode::Char('t'), + modifiers: KeyModifiers::CONTROL, + display: ShortcutDisplay::Ctrl('T'), + condition: DisplayCondition::Always, + }], + footer_label: " transcript ", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::Quit, + bindings: &[ShortcutBinding { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + display: ShortcutDisplay::Ctrl('C'), + condition: DisplayCondition::Always, + }], + footer_label: " quit", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + display: ShortcutDisplay::Plain("Esc"), + condition: DisplayCondition::Always, + }], + footer_label: " edit prev", + footer_prefix: " ", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::protocol::TokenUsage; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn snapshot_footer(name: &str, props: FooterProps<'_>) { + let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap(); + terminal + .draw(|f| { + let area = Rect::new(0, 0, f.area().width, 1); + render_footer(area, f.buffer_mut(), props); + }) + .unwrap(); + assert_snapshot!(name, terminal.backend()); + } + + fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo { + let usage = TokenUsage { + input_tokens: total_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens, + }; + let last = TokenUsage { + input_tokens: last_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: last_tokens, + }; + TokenUsageInfo { + total_token_usage: usage, + last_token_usage: last, + model_context_window: Some(context_window), + } + } + + #[test] + fn footer_snapshots() { + snapshot_footer( + "footer_shortcuts_default", + FooterProps { + ctrl_c_quit_hint: false, + is_task_running: false, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + token_usage_info: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_shift_and_esc", + FooterProps { + ctrl_c_quit_hint: false, + is_task_running: false, + esc_backtrack_hint: true, + use_shift_enter_hint: true, + token_usage_info: Some(&token_usage(4_200, 900, 8_000)), + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_idle", + FooterProps { + ctrl_c_quit_hint: true, + is_task_running: false, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + token_usage_info: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_running", + FooterProps { + ctrl_c_quit_hint: true, + is_task_running: true, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + token_usage_info: None, + }, + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index bd26581b47..61a2378af2 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -23,6 +23,7 @@ mod chat_composer_history; mod command_popup; pub mod custom_prompt_view; mod file_search_popup; +mod footer; mod list_selection_view; pub(crate) use list_selection_view::SelectionViewParams; mod paste_burst; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap new file mode 100644 index 0000000000..32482ba1cd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ⌃C again to quit " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap new file mode 100644 index 0000000000..8c9c2b0d46 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ⌃C again to interrupt " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap new file mode 100644 index 0000000000..8c8a10df85 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap new file mode 100644 index 0000000000..9b94d89f98 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use" +" " +" " From 41f5d61f24da9475a63954560535d4c969f66dee Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 26 Sep 2025 07:13:29 -0700 Subject: [PATCH 03/31] Move approvals to use ListSelectionView (#4275) Unify selection menus: - Move approvals to the vertical menu `ListSelectionView` - Add header section to `ListSelectionView` image image --------- Co-authored-by: pakrym-oai --- .../src/bottom_pane/approval_modal_view.rs | 111 ---- .../tui/src/bottom_pane/approval_overlay.rs | 559 ++++++++++++++++++ .../tui/src/bottom_pane/bottom_pane_view.rs | 2 +- .../src/bottom_pane/list_selection_view.rs | 114 +++- codex-rs/tui/src/bottom_pane/mod.rs | 8 +- codex-rs/tui/src/chatwidget.rs | 2 +- ...hatwidget__tests__approval_modal_exec.snap | 15 +- ..._tests__approval_modal_exec_no_reason.snap | 13 +- ...atwidget__tests__approval_modal_patch.snap | 15 +- ...sts__status_widget_and_approval_modal.snap | 15 +- codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/user_approval_widget.rs | 448 -------------- 12 files changed, 705 insertions(+), 598 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/approval_modal_view.rs create mode 100644 codex-rs/tui/src/bottom_pane/approval_overlay.rs delete mode 100644 codex-rs/tui/src/user_approval_widget.rs diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs deleted file mode 100644 index 912df6ce5a..0000000000 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ /dev/null @@ -1,111 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; - -use crate::app_event_sender::AppEventSender; -use crate::user_approval_widget::ApprovalRequest; -use crate::user_approval_widget::UserApprovalWidget; - -use super::BottomPaneView; -use super::CancellationEvent; - -/// Modal overlay asking the user to approve/deny a sequence of requests. -pub(crate) struct ApprovalModalView { - current: UserApprovalWidget, - queue: Vec, - app_event_tx: AppEventSender, -} - -impl ApprovalModalView { - pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { - Self { - current: UserApprovalWidget::new(request, app_event_tx.clone()), - queue: Vec::new(), - app_event_tx, - } - } - - pub fn enqueue_request(&mut self, req: ApprovalRequest) { - self.queue.push(req); - } - - /// Advance to next request if the current one is finished. - fn maybe_advance(&mut self) { - if self.current.is_complete() - && let Some(req) = self.queue.pop() - { - self.current = UserApprovalWidget::new(req, self.app_event_tx.clone()); - } - } -} - -impl BottomPaneView for ApprovalModalView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - self.current.handle_key_event(key_event); - self.maybe_advance(); - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - self.current.on_ctrl_c(); - self.queue.clear(); - CancellationEvent::Handled - } - - fn is_complete(&self) -> bool { - self.current.is_complete() && self.queue.is_empty() - } - - fn desired_height(&self, width: u16) -> u16 { - self.current.desired_height(width) - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - (&self.current).render_ref(area, buf); - } - - fn try_consume_approval_request(&mut self, req: ApprovalRequest) -> Option { - self.enqueue_request(req); - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use crate::bottom_pane::BottomPane; - use tokio::sync::mpsc::unbounded_channel; - - fn make_exec_request() -> ApprovalRequest { - ApprovalRequest::Exec { - id: "test".to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - reason: None, - } - } - - #[test] - fn ctrl_c_aborts_and_clears_queue() { - let (tx, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let first = make_exec_request(); - let mut view = ApprovalModalView::new(first, tx); - view.enqueue_request(make_exec_request()); - - let (tx2, _rx2) = unbounded_channel::(); - // Why do we have this? - let _pane = BottomPane::new(super::super::BottomPaneParams { - app_event_tx: AppEventSender::new(tx2), - frame_requester: crate::tui::FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); - assert!(view.queue.is_empty()); - assert!(view.current.is_complete()); - assert!(view.is_complete()); - } -} diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs new file mode 100644 index 0000000000..7ab2b79a57 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -0,0 +1,559 @@ +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::HeaderLine; +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::exec_command::strip_bash_lc_and_escape; +use crate::history_cell; +use crate::text_formatting::truncate_text; +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 ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; + +/// Request coming from the agent that needs user approval. +pub(crate) enum ApprovalRequest { + Exec { + id: String, + command: Vec, + reason: Option, + }, + ApplyPatch { + id: String, + reason: Option, + grant_root: Option, + }, +} + +/// Modal overlay asking the user to approve or deny one or more requests. +pub(crate) struct ApprovalOverlay { + current: Option, + queue: Vec, + app_event_tx: AppEventSender, + list: ListSelectionView, + options: Vec, + current_complete: bool, + done: bool, +} + +impl ApprovalOverlay { + pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { + let mut view = Self { + current: Some(ApprovalRequestState::from(request)), + queue: Vec::new(), + app_event_tx: app_event_tx.clone(), + list: ListSelectionView::new( + SelectionViewParams { + title: String::new(), + ..Default::default() + }, + app_event_tx, + ), + options: Vec::new(), + current_complete: false, + done: false, + }; + let (options, params) = view.build_options(); + view.options = options; + view.list = ListSelectionView::new(params, view.app_event_tx.clone()); + view + } + + pub fn enqueue_request(&mut self, req: ApprovalRequest) { + self.queue.push(req); + } + + fn set_current(&mut self, request: ApprovalRequest) { + self.current = Some(ApprovalRequestState::from(request)); + self.current_complete = false; + let (options, params) = self.build_options(); + self.options = options; + self.list = ListSelectionView::new(params, self.app_event_tx.clone()); + } + + fn build_options(&self) -> (Vec, SelectionViewParams) { + let Some(state) = self.current.as_ref() else { + return ( + Vec::new(), + SelectionViewParams { + title: String::new(), + ..Default::default() + }, + ); + }; + let (options, title) = match &state.variant { + ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()), + ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()), + }; + + let items = options + .iter() + .map(|opt| SelectionItem { + name: opt.label.clone(), + description: Some(opt.description.clone()), + is_current: false, + actions: Vec::new(), + dismiss_on_select: false, + search_value: None, + }) + .collect(); + + let params = SelectionViewParams { + title, + footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()), + items, + header: state.header.clone(), + ..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(state) = self.current.as_ref() { + match (&state.variant, option.decision) { + (ApprovalVariant::Exec { id, command }, decision) => { + self.handle_exec_decision(id, command, decision); + } + (ApprovalVariant::ApplyPatch { id, .. }, decision) => { + self.handle_patch_decision(id, decision); + } + } + } + + self.current_complete = true; + self.advance_queue(); + } + + fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { + if let Some(lines) = build_exec_history_lines(command.to_vec(), decision) { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_user_approval_decision(lines), + ))); + } + 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 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 { + if key_event.kind != KeyEventKind::Press { + return false; + } + let KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } = key_event + else { + return false; + }; + if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) { + return false; + } + let lower = c.to_ascii_lowercase(); + if let Some(idx) = self + .options + .iter() + .position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false)) + { + 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(state) = self.current.as_ref() + { + match &state.variant { + ApprovalVariant::Exec { id, command } => { + self.handle_exec_decision(id, command, ReviewDecision::Abort); + } + ApprovalVariant::ApplyPatch { id, .. } => { + self.handle_patch_decision(id, ReviewDecision::Abort); + } + } + } + self.queue.clear(); + self.done = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.done + } + + 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 try_consume_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Option { + self.enqueue_request(request); + None + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.list.cursor_pos(area) + } +} + +struct ApprovalRequestState { + variant: ApprovalVariant, + header: Vec, +} + +impl From for ApprovalRequestState { + fn from(value: ApprovalRequest) -> Self { + match value { + ApprovalRequest::Exec { + id, + command, + reason, + } => { + let mut header = Vec::new(); + if let Some(reason) = reason + && !reason.is_empty() + { + header.push(HeaderLine::Text { + text: reason, + italic: true, + }); + header.push(HeaderLine::Spacer); + } + let command_snippet = exec_snippet(&command); + if !command_snippet.is_empty() { + header.push(HeaderLine::Text { + text: format!("Command: {command_snippet}"), + italic: false, + }); + header.push(HeaderLine::Spacer); + } + Self { + variant: ApprovalVariant::Exec { id, command }, + header, + } + } + ApprovalRequest::ApplyPatch { + id, + reason, + grant_root, + } => { + let mut header = Vec::new(); + if let Some(reason) = reason + && !reason.is_empty() + { + header.push(HeaderLine::Text { + text: reason, + italic: true, + }); + header.push(HeaderLine::Spacer); + } + if let Some(root) = grant_root { + header.push(HeaderLine::Text { + text: format!( + "Grant write access to {} for the remainder of this session.", + root.display() + ), + italic: false, + }); + header.push(HeaderLine::Spacer); + } + Self { + variant: ApprovalVariant::ApplyPatch { id }, + header, + } + } + } + } +} + +enum ApprovalVariant { + Exec { id: String, command: Vec }, + ApplyPatch { id: String }, +} + +#[derive(Clone)] +struct ApprovalOption { + label: String, + description: String, + decision: ReviewDecision, + shortcut: Option, +} + +fn exec_options() -> Vec { + vec![ + ApprovalOption { + label: "Approve and run now".to_string(), + description: "(Y) Run this command one time".to_string(), + decision: ReviewDecision::Approved, + shortcut: Some('y'), + }, + ApprovalOption { + label: "Always approve this session".to_string(), + description: "(A) Automatically approve this command for the rest of the session" + .to_string(), + decision: ReviewDecision::ApprovedForSession, + shortcut: Some('a'), + }, + ApprovalOption { + label: "Cancel".to_string(), + description: "(N) Do not run the command".to_string(), + decision: ReviewDecision::Abort, + shortcut: Some('n'), + }, + ] +} + +fn patch_options() -> Vec { + vec![ + ApprovalOption { + label: "Approve".to_string(), + description: "(Y) Apply the proposed changes".to_string(), + decision: ReviewDecision::Approved, + shortcut: Some('y'), + }, + ApprovalOption { + label: "Cancel".to_string(), + description: "(N) Do not apply the changes".to_string(), + decision: ReviewDecision::Abort, + shortcut: Some('n'), + }, + ] +} + +fn build_exec_history_lines( + command: Vec, + decision: ReviewDecision, +) -> Option>> { + use 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(), + ], + ) + } + 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, + ], + ) + } + }; + + let mut lines = Vec::new(); + let mut spans = Vec::new(); + spans.push(symbol); + spans.extend(summary); + lines.push(Line::from(spans)); + Some(lines) +} + +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) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + 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()), + } + } + + #[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); + 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); + 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 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, + }; + + let view = ApprovalOverlay::new(exec_request, tx); + let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6)); + view.render(Rect::new(0, 0, 80, 6), &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("Command: echo hello world")), + "expected header to include command snippet, got {rendered:?}" + ); + } + + #[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); + view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + 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::ApprovedForSession)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 6bc436f9dc..8f569ec863 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,4 +1,4 @@ -use crate::user_approval_widget::ApprovalRequest; +use crate::bottom_pane::ApprovalRequest; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; 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 a856a3c634..c95f1ee049 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -8,6 +8,7 @@ use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use textwrap::wrap; use crate::app_event_sender::AppEventSender; @@ -22,6 +23,12 @@ use super::selection_popup_common::render_rows; /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum HeaderLine { + Text { text: String, italic: bool }, + Spacer, +} + pub(crate) struct SelectionItem { pub name: String, pub description: Option, @@ -39,6 +46,7 @@ pub(crate) struct SelectionViewParams { pub items: Vec, pub is_searchable: bool, pub search_placeholder: Option, + pub header: Vec, } pub(crate) struct ListSelectionView { @@ -53,6 +61,8 @@ pub(crate) struct ListSelectionView { search_query: String, search_placeholder: Option, filtered_indices: Vec, + last_selected_actual_idx: Option, + header: Vec, } impl ListSelectionView { @@ -82,6 +92,8 @@ impl ListSelectionView { None }, filtered_indices: Vec::new(), + last_selected_actual_idx: None, + header: params.header, }; s.apply_filter(); s @@ -198,6 +210,7 @@ impl ListSelectionView { && 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); } @@ -214,6 +227,43 @@ impl ListSelectionView { self.search_query = query; self.apply_filter(); } + + pub(crate) fn take_last_selected_index(&mut self) -> Option { + self.last_selected_actual_idx.take() + } + + fn header_spans_for_width(&self, width: u16) -> Vec>> { + if self.header.is_empty() || width == 0 { + return Vec::new(); + } + let prefix_width = Self::dim_prefix_span().width() as u16; + let available = width.saturating_sub(prefix_width).max(1) as usize; + let mut lines = Vec::new(); + for entry in &self.header { + match entry { + HeaderLine::Spacer => lines.push(Vec::new()), + HeaderLine::Text { text, italic } => { + if text.is_empty() { + lines.push(Vec::new()); + continue; + } + for part in wrap(text, available) { + let span = if *italic { + Span::from(part.into_owned()).italic() + } else { + Span::from(part.into_owned()) + }; + lines.push(vec![span]); + } + } + } + } + lines + } + + fn header_height(&self, width: u16) -> u16 { + self.header_spans_for_width(width).len() as u16 + } } impl BottomPaneView for ListSelectionView { @@ -276,7 +326,8 @@ impl BottomPaneView for ListSelectionView { // +1 for the title row, +1 for a spacer line beneath the header, // +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing) - let mut height = rows_height + 2; + let mut height = self.header_height(width); + height = height.saturating_add(rows_height + 2); if self.is_searchable { height = height.saturating_add(1); } @@ -295,20 +346,46 @@ impl BottomPaneView for ListSelectionView { return; } + let mut next_y = area.y; + let header_spans = self.header_spans_for_width(area.width); + for spans in header_spans.into_iter() { + if next_y >= area.y + area.height { + return; + } + let row = Rect { + x: area.x, + y: next_y, + width: area.width, + height: 1, + }; + let mut prefixed: Vec> = vec![Self::dim_prefix_span()]; + if spans.is_empty() { + prefixed.push(String::new().into()); + } else { + prefixed.extend(spans); + } + Paragraph::new(Line::from(prefixed)).render(row, buf); + next_y = next_y.saturating_add(1); + } + + if next_y >= area.y + area.height { + return; + } + let title_area = Rect { x: area.x, - y: area.y, + y: next_y, width: area.width, height: 1, }; + Paragraph::new(Line::from(vec![ + Self::dim_prefix_span(), + self.title.clone().bold(), + ])) + .render(title_area, buf); + next_y = next_y.saturating_add(1); - let title_spans: Vec> = - vec![Self::dim_prefix_span(), self.title.clone().bold()]; - let title_para = Paragraph::new(Line::from(title_spans)); - title_para.render(title_area, buf); - - let mut next_y = area.y.saturating_add(1); - if self.is_searchable { + if self.is_searchable && next_y < area.y + area.height { let search_area = Rect { x: area.x, y: next_y, @@ -327,20 +404,25 @@ impl BottomPaneView for ListSelectionView { .render(search_area, buf); next_y = next_y.saturating_add(1); } + if let Some(sub) = &self.subtitle { + if next_y >= area.y + area.height { + return; + } let subtitle_area = Rect { x: area.x, y: next_y, width: area.width, height: 1, }; - let subtitle_spans: Vec> = - vec![Self::dim_prefix_span(), sub.clone().dim()]; - let subtitle_para = Paragraph::new(Line::from(subtitle_spans)); - subtitle_para.render(subtitle_area, buf); + Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()])) + .render(subtitle_area, buf); next_y = next_y.saturating_add(1); } + if next_y >= area.y + area.height { + return; + } let spacer_area = Rect { x: area.x, y: next_y, @@ -351,6 +433,9 @@ impl BottomPaneView for ListSelectionView { next_y = next_y.saturating_add(1); let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 }; + if next_y >= area.y + area.height { + return; + } let rows_area = Rect { x: area.x, y: next_y, @@ -381,8 +466,7 @@ impl BottomPaneView for ListSelectionView { width: area.width, height: 1, }; - let footer_para = Paragraph::new(hint.clone().dim()); - footer_para.render(footer_area, buf); + Paragraph::new(hint.clone().dim()).render(footer_area, buf); } } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 61a2378af2..062ccdbb59 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::tui::FrameRequester; -use crate::user_approval_widget::ApprovalRequest; use bottom_pane_view::BottomPaneView; use codex_core::protocol::TokenUsageInfo; use codex_file_search::FileMatch; @@ -16,7 +15,9 @@ use ratatui::layout::Rect; use ratatui::widgets::WidgetRef; use std::time::Duration; -mod approval_modal_view; +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; @@ -43,7 +44,6 @@ pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; use crate::status_indicator_widget::StatusIndicatorWidget; -use approval_modal_view::ApprovalModalView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; @@ -397,7 +397,7 @@ impl BottomPane { }; // Otherwise create a new approval modal overlay. - let modal = ApprovalModalView::new(request, self.app_event_tx.clone()); + let modal = ApprovalOverlay::new(request, self.app_event_tx.clone()); self.pause_status_timer_for_modal(); self.push_view(Box::new(modal)); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2a9eaab33c..4678dd242a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -84,7 +84,7 @@ use crate::slash_command::SlashCommand; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; // streaming internals are provided by crate::streaming and crate::markdown_stream -use crate::user_approval_widget::ApprovalRequest; +use crate::bottom_pane::ApprovalRequest; mod interrupts; use self::interrupts::InterruptManager; mod agent; 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 6087853902..304adf547e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -"this is a test reason such as one that would be produced by the model " +"▌ this is a test reason such as one that would be produced by the model " +"▌ " +"▌ Command: echo hello world " +"▌ " +"▌ Allow command? " +"▌ " +"▌ > 1. Approve and run now (Y) Run this command one time " +"▌ 2. Always approve this session (A) Automatically approve this command for " +"▌ the rest of the session " +"▌ 3. Cancel (N) Do not run the command " " " -"▌Allow command? " -"▌ Yes Always No, provide feedback " -"▌ Approve and run the command " +"Press Enter to confirm or Esc to cancel " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index fb1ecce7a6..239681acb0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -3,7 +3,14 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -"▌Allow command? " -"▌ Yes Always No, provide feedback " -"▌ Approve and run the command " +"▌ Command: echo hello world " +"▌ " +"▌ Allow command? " +"▌ " +"▌ > 1. Approve and run now (Y) Run this command one time " +"▌ 2. Always approve this session (A) Automatically approve this command for " +"▌ the rest of the session " +"▌ 3. Cancel (N) Do not run the command " +" " +"Press Enter to confirm or Esc to cancel " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index 183a295063..2ee14a57f2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -3,11 +3,14 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -"The model wants to apply changes " +"▌ The model wants to apply changes " +"▌ " +"▌ Grant write access to /tmp for the remainder of this session. " +"▌ " +"▌ Apply changes? " +"▌ " +"▌ > 1. Approve (Y) Apply the proposed changes " +"▌ 2. Cancel (N) Do not apply the changes " " " -"This will grant write access to /tmp for the remainder of this session. " -" " -"▌Apply changes? " -"▌ Yes No, provide feedback " -"▌ Approve and apply the changes " +"Press Enter to confirm or Esc to cancel " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 6087853902..e762896fd7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -"this is a test reason such as one that would be produced by the model " +"▌ this is a test reason such as one that would be produced by the model " +"▌ " +"▌ Command: echo 'hello world' " +"▌ " +"▌ Allow command? " +"▌ " +"▌ > 1. Approve and run now (Y) Run this command one time " +"▌ 2. Always approve this session (A) Automatically approve this command for " +"▌ the rest of the session " +"▌ 3. Cancel (N) Do not run the command " " " -"▌Allow command? " -"▌ Yes Always No, provide feedback " -"▌ Approve and run the command " +"Press Enter to confirm or Esc to cancel " " " diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1453971ca4..8f51bc9d84 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -66,7 +66,6 @@ mod streaming; mod text_formatting; mod tui; mod ui_consts; -mod user_approval_widget; mod version; mod wrapping; diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs deleted file mode 100644 index 410766e0fe..0000000000 --- a/codex-rs/tui/src/user_approval_widget.rs +++ /dev/null @@ -1,448 +0,0 @@ -//! A modal widget that prompts the user to approve or deny an action -//! requested by the agent. -//! -//! This is a (very) rough port of -//! `src/components/chat/terminal-chat-command-review.tsx` from the TypeScript -//! UI to Rust using [`ratatui`]. The goal is feature‑parity for the keyboard -//! driven workflow – a fully‑fledged visual match is not required. - -use std::path::PathBuf; -use std::sync::LazyLock; - -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::prelude::*; -use ratatui::text::Line; -use ratatui::widgets::Block; -use ratatui::widgets::BorderType; -use ratatui::widgets::Borders; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::history_cell; -use crate::text_formatting::truncate_text; - -/// Request coming from the agent that needs user approval. -pub(crate) enum ApprovalRequest { - Exec { - id: String, - command: Vec, - reason: Option, - }, - ApplyPatch { - id: String, - reason: Option, - grant_root: Option, - }, -} - -/// Options displayed in the *select* mode. -/// -/// The `key` is matched case-insensitively. -struct SelectOption { - label: Line<'static>, - description: &'static str, - key: KeyCode, - decision: ReviewDecision, -} - -static COMMAND_SELECT_OPTIONS: LazyLock> = LazyLock::new(|| { - vec![ - SelectOption { - label: Line::from(vec!["Y".underlined(), "es".into()]), - description: "Approve and run the command", - key: KeyCode::Char('y'), - decision: ReviewDecision::Approved, - }, - SelectOption { - label: Line::from(vec!["A".underlined(), "lways".into()]), - description: "Approve the command for the remainder of this session", - key: KeyCode::Char('a'), - decision: ReviewDecision::ApprovedForSession, - }, - SelectOption { - label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]), - description: "Do not run the command; provide feedback", - key: KeyCode::Char('n'), - decision: ReviewDecision::Abort, - }, - ] -}); - -static PATCH_SELECT_OPTIONS: LazyLock> = LazyLock::new(|| { - vec![ - SelectOption { - label: Line::from(vec!["Y".underlined(), "es".into()]), - description: "Approve and apply the changes", - key: KeyCode::Char('y'), - decision: ReviewDecision::Approved, - }, - SelectOption { - label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]), - description: "Do not apply the changes; provide feedback", - key: KeyCode::Char('n'), - decision: ReviewDecision::Abort, - }, - ] -}); - -/// A modal prompting the user to approve or deny the pending request. -pub(crate) struct UserApprovalWidget { - approval_request: ApprovalRequest, - app_event_tx: AppEventSender, - confirmation_prompt: Paragraph<'static>, - select_options: &'static Vec, - - /// Currently selected index in *select* mode. - selected_option: usize, - - /// Set to `true` once a decision has been sent – the parent view can then - /// remove this widget from its queue. - done: bool, -} - -impl UserApprovalWidget { - pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { - let confirmation_prompt = match &approval_request { - ApprovalRequest::Exec { reason, .. } => { - let mut contents: Vec = vec![]; - if let Some(reason) = reason { - contents.push(Line::from(reason.clone().italic())); - contents.push(Line::from("")); - } - Paragraph::new(contents).wrap(Wrap { trim: false }) - } - ApprovalRequest::ApplyPatch { - reason, grant_root, .. - } => { - let mut contents: Vec = vec![]; - - if let Some(r) = reason { - contents.push(Line::from(r.clone().italic())); - contents.push(Line::from("")); - } - - if let Some(root) = grant_root { - contents.push(Line::from(format!( - "This will grant write access to {} for the remainder of this session.", - root.display() - ))); - contents.push(Line::from("")); - } - - Paragraph::new(contents).wrap(Wrap { trim: false }) - } - }; - - Self { - select_options: match &approval_request { - ApprovalRequest::Exec { .. } => &COMMAND_SELECT_OPTIONS, - ApprovalRequest::ApplyPatch { .. } => &PATCH_SELECT_OPTIONS, - }, - approval_request, - app_event_tx, - confirmation_prompt, - selected_option: 0, - done: false, - } - } - - fn get_confirmation_prompt_height(&self, width: u16) -> u16 { - // Should cache this for last value of width. - self.confirmation_prompt.line_count(width) as u16 - } - - /// Process a `KeyEvent` coming from crossterm. Always consumes the event - /// while the modal is visible. - /// Process a key event originating from crossterm. As the modal fully - /// captures input while visible, we don’t need to report whether the event - /// was consumed—callers can assume it always is. - pub(crate) fn handle_key_event(&mut self, key: KeyEvent) { - if key.kind == KeyEventKind::Press { - self.handle_select_key(key); - } - } - - /// Normalize a key for comparison. - /// - For `KeyCode::Char`, converts to lowercase for case-insensitive matching. - /// - Other key codes are returned unchanged. - fn normalize_keycode(code: KeyCode) -> KeyCode { - match code { - KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()), - other => other, - } - } - - /// Handle Ctrl-C pressed by the user while the modal is visible. - /// Behaves like pressing Escape: abort the request and close the modal. - pub(crate) fn on_ctrl_c(&mut self) { - self.send_decision(ReviewDecision::Abort); - } - - fn handle_select_key(&mut self, key_event: KeyEvent) { - match key_event.code { - KeyCode::Left => { - self.selected_option = (self.selected_option + self.select_options.len() - 1) - % self.select_options.len(); - } - KeyCode::Right => { - self.selected_option = (self.selected_option + 1) % self.select_options.len(); - } - KeyCode::Enter => { - let opt = &self.select_options[self.selected_option]; - self.send_decision(opt.decision); - } - KeyCode::Esc => { - self.send_decision(ReviewDecision::Abort); - } - other => { - let normalized = Self::normalize_keycode(other); - if let Some(opt) = self - .select_options - .iter() - .find(|opt| Self::normalize_keycode(opt.key) == normalized) - { - self.send_decision(opt.decision); - } - } - } - } - - fn send_decision(&mut self, decision: ReviewDecision) { - self.send_decision_with_feedback(decision, String::new()) - } - - fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) { - match &self.approval_request { - ApprovalRequest::Exec { command, .. } => { - let full_cmd = strip_bash_lc_and_escape(command); - // Construct a concise, single-line summary of the command: - // - If multi-line, take the first line and append " ...". - // - Truncate to 80 graphemes. - let mut snippet = match full_cmd.split_once('\n') { - Some((first, _)) => format!("{first} ..."), - None => full_cmd.clone(), - }; - // Enforce the 80 character length limit. - snippet = truncate_text(&snippet, 80); - - let mut result_spans: Vec> = Vec::new(); - match decision { - ReviewDecision::Approved => { - result_spans.extend(vec![ - "✔ ".fg(Color::Green), - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet.dim(), - " this time".bold(), - ]); - } - ReviewDecision::ApprovedForSession => { - result_spans.extend(vec![ - "✔ ".fg(Color::Green), - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet.dim(), - " every time this session".bold(), - ]); - } - ReviewDecision::Denied => { - result_spans.extend(vec![ - "✗ ".fg(Color::Red), - "You ".into(), - "did not approve".bold(), - " codex to run ".into(), - snippet.dim(), - ]); - } - ReviewDecision::Abort => { - result_spans.extend(vec![ - "✗ ".fg(Color::Red), - "You ".into(), - "canceled".bold(), - " the request to run ".into(), - snippet.dim(), - ]); - } - } - - let mut lines: Vec> = vec![Line::from(result_spans)]; - - if !feedback.trim().is_empty() { - lines.push(Line::from("feedback:")); - for l in feedback.lines() { - lines.push(Line::from(l.to_string())); - } - } - - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_user_approval_decision(lines), - ))); - } - ApprovalRequest::ApplyPatch { .. } => { - // No history line for patch approval decisions. - } - } - - let op = match &self.approval_request { - ApprovalRequest::Exec { id, .. } => Op::ExecApproval { - id: id.clone(), - decision, - }, - ApprovalRequest::ApplyPatch { id, .. } => Op::PatchApproval { - id: id.clone(), - decision, - }, - }; - - self.app_event_tx.send(AppEvent::CodexOp(op)); - self.done = true; - } - - /// Returns `true` once the user has made a decision and the widget no - /// longer needs to be displayed. - pub(crate) fn is_complete(&self) -> bool { - self.done - } - - pub(crate) fn desired_height(&self, width: u16) -> u16 { - // Reserve space for: - // - 1 title line ("Allow command?" or "Apply changes?") - // - 1 buttons line (options rendered horizontally on a single row) - // - 1 description line (context for the currently selected option) - self.get_confirmation_prompt_height(width) + 3 - } -} - -impl WidgetRef for &UserApprovalWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let prompt_height = self.get_confirmation_prompt_height(area.width); - let [prompt_chunk, response_chunk] = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(prompt_height), Constraint::Min(0)]) - .areas(area); - - let lines: Vec = self - .select_options - .iter() - .enumerate() - .map(|(idx, opt)| { - let style = if idx == self.selected_option { - Style::new().bg(Color::Cyan).fg(Color::Black) - } else { - Style::new().add_modifier(Modifier::DIM) - }; - opt.label.clone().alignment(Alignment::Center).style(style) - }) - .collect(); - - let [title_area, button_area, description_area] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas(response_chunk.inner(Margin::new(1, 0))); - let title = match &self.approval_request { - ApprovalRequest::Exec { .. } => "Allow command?", - ApprovalRequest::ApplyPatch { .. } => "Apply changes?", - }; - Line::from(title).render(title_area, buf); - - self.confirmation_prompt.clone().render(prompt_chunk, buf); - let areas = Layout::horizontal( - lines - .iter() - .map(|l| Constraint::Length(l.width() as u16 + 2)), - ) - .spacing(1) - .split(button_area); - for (idx, area) in areas.iter().enumerate() { - let line = &lines[idx]; - line.render(*area, buf); - } - - Line::from(self.select_options[self.selected_option].description) - .style(Style::new().italic().add_modifier(Modifier::DIM)) - .render(description_area.inner(Margin::new(1, 0)), buf); - - Block::bordered() - .border_type(BorderType::QuadrantOutside) - .border_style(Style::default().fg(Color::Cyan)) - .borders(Borders::LEFT) - .render_ref( - Rect::new(0, response_chunk.y, 1, response_chunk.height), - buf, - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - use tokio::sync::mpsc::unbounded_channel; - - #[test] - fn lowercase_shortcut_is_accepted() { - let (tx_raw, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let req = ApprovalRequest::Exec { - id: "1".to_string(), - command: vec!["echo".to_string()], - reason: None, - }; - let mut widget = UserApprovalWidget::new(req, tx); - widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(widget.is_complete()); - let mut events: Vec = Vec::new(); - while let Ok(ev) = rx.try_recv() { - events.push(ev); - } - assert!(events.iter().any(|e| matches!( - e, - AppEvent::CodexOp(Op::ExecApproval { - decision: ReviewDecision::Approved, - .. - }) - ))); - } - - #[test] - fn uppercase_shortcut_is_accepted() { - let (tx_raw, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let req = ApprovalRequest::Exec { - id: "2".to_string(), - command: vec!["echo".to_string()], - reason: None, - }; - let mut widget = UserApprovalWidget::new(req, tx); - widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE)); - assert!(widget.is_complete()); - let mut events: Vec = Vec::new(); - while let Ok(ev) = rx.try_recv() { - events.push(ev); - } - assert!(events.iter().any(|e| matches!( - e, - AppEvent::CodexOp(Op::ExecApproval { - decision: ReviewDecision::Approved, - .. - }) - ))); - } -} From a53720e278325ad7b35bfdde23ac017f22eb4981 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 26 Sep 2025 07:13:44 -0700 Subject: [PATCH 04/31] Show exec output on success with trimmed display (#4113) - Refactor Exec Cell into its own module - update exec command rendering to inline the first command line - limit continuation lines - always show trimmed output --- codex-rs/tui/src/chatwidget.rs | 9 +- ...et__tests__binary_size_ideal_response.snap | 14 +- codex-rs/tui/src/exec_cell/mod.rs | 12 + codex-rs/tui/src/exec_cell/model.rs | 123 +++++ codex-rs/tui/src/exec_cell/render.rs | 492 ++++++++++++++++++ codex-rs/tui/src/history_cell.rs | 471 +---------------- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/pager_overlay.rs | 4 +- ...both_lines_wrap_with_correct_prefixes.snap | 10 +- ...ut_wrap_uses_branch_then_eight_spaces.snap | 5 +- ...with_extra_indent_on_subsequent_lines.snap | 9 +- ...n_cell_multiline_with_stderr_snapshot.snap | 12 +- ...nd_wraps_with_four_space_continuation.snap | 8 +- ...rr_tail_more_than_five_lines_snapshot.snap | 7 +- 14 files changed, 671 insertions(+), 506 deletions(-) create mode 100644 codex-rs/tui/src/exec_cell/mod.rs create mode 100644 codex-rs/tui/src/exec_cell/model.rs create mode 100644 codex-rs/tui/src/exec_cell/render.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4678dd242a..a373338f58 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -70,11 +70,12 @@ 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::CommandOutput; -use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PatchEventType; @@ -673,7 +674,7 @@ impl ChatWidget { .unwrap_or(true); if needs_new { self.flush_active_cell(); - self.active_cell = Some(Box::new(history_cell::new_active_exec_command( + self.active_cell = Some(Box::new(new_active_exec_command( ev.call_id.clone(), command, parsed, @@ -777,7 +778,7 @@ impl ChatWidget { } else { self.flush_active_cell(); - self.active_cell = Some(Box::new(history_cell::new_active_exec_command( + self.active_cell = Some(Box::new(new_active_exec_command( ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap index caabd816bb..bca67dfebe 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -17,11 +17,15 @@ expression: visible_after 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; - done +• 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] + … +7 lines + ] } + tracing = { version • Explored └ Read Cargo.toml diff --git a/codex-rs/tui/src/exec_cell/mod.rs b/codex-rs/tui/src/exec_cell/mod.rs new file mode 100644 index 0000000000..906091113e --- /dev/null +++ b/codex-rs/tui/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/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs new file mode 100644 index 0000000000..2893a005dc --- /dev/null +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -0,0 +1,123 @@ +use std::time::Duration; +use std::time::Instant; + +use codex_protocol::parse_command::ParsedCommand; + +#[derive(Clone, Debug)] +pub(crate) struct CommandOutput { + pub(crate) exit_code: i32, + pub(crate) stdout: String, + pub(crate) stderr: String, + 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) start_time: Option, + pub(crate) duration: Option, +} + +#[derive(Debug)] +pub(crate) struct ExecCell { + pub(crate) calls: Vec, +} + +impl ExecCell { + pub(crate) fn new(call: ExecCall) -> Self { + Self { calls: vec![call] } + } + + pub(crate) fn with_added_call( + &self, + call_id: String, + command: Vec, + parsed: Vec, + ) -> Option { + let call = ExecCall { + call_id, + command, + parsed, + output: None, + start_time: Some(Instant::now()), + duration: None, + }; + if self.is_exploring_cell() && Self::is_exploring_call(&call) { + Some(Self { + calls: [self.calls.clone(), vec![call]].concat(), + }) + } else { + None + } + } + + pub(crate) fn complete_call( + &mut self, + call_id: &str, + output: CommandOutput, + duration: Duration, + ) { + if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { + call.output = Some(output); + call.duration = Some(duration); + call.start_time = None; + } + } + + pub(crate) fn should_flush(&self) -> bool { + !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) + } + + 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, + stdout: String::new(), + stderr: String::new(), + formatted_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 iter_calls(&self) -> impl Iterator { + self.calls.iter() + } + + pub(super) fn is_exploring_call(call: &ExecCall) -> bool { + !call.parsed.is_empty() + && call.parsed.iter().all(|p| { + matches!( + p, + ParsedCommand::Read { .. } + | ParsedCommand::ListFiles { .. } + | ParsedCommand::Search { .. } + ) + }) + } +} diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs new file mode 100644 index 0000000000..a096dbe534 --- /dev/null +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -0,0 +1,492 @@ +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::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; +use codex_ansi_escape::ansi_escape_line; +use codex_common::elapsed::format_duration; +use codex_protocol::parse_command::ParsedCommand; +use itertools::Itertools; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::style::Stylize; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; +use textwrap::WordSplitter; +use unicode_width::UnicodeWidthStr; + +pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; + +pub(crate) struct OutputLinesParams { + 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, +) -> ExecCell { + ExecCell::new(ExecCall { + call_id, + command, + parsed, + output: None, + start_time: Some(Instant::now()), + duration: None, + }) +} + +pub(crate) fn output_lines( + output: Option<&CommandOutput>, + params: OutputLinesParams, +) -> Vec> { + let OutputLinesParams { + only_err, + include_angle_pipe, + include_prefix, + } = params; + let CommandOutput { + exit_code, + stdout, + stderr, + .. + } = match output { + Some(output) if only_err && output.exit_code == 0 => return vec![], + Some(output) => output, + None => return vec![], + }; + + let src = if *exit_code == 0 { stdout } else { stderr }; + let lines: Vec<&str> = src.lines().collect(); + let total = lines.len(); + let limit = TOOL_CALL_MAX_LINES; + + let mut out = Vec::new(); + + let head_end = total.min(limit); + for (i, raw) in lines[..head_end].iter().enumerate() { + let mut line = ansi_escape_line(raw); + let prefix = if !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 * limit; + if show_ellipsis { + let omitted = total - 2 * limit; + out.push(format!("… +{omitted} lines").into()); + } + + let tail_start = if show_ellipsis { + total - 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); + } + + out +} + +pub(crate) fn spinner(start_time: Option) -> Span<'static> { + const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let idx = start_time + .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) + .unwrap_or(0); + let ch = FRAMES[idx]; + ch.to_string().into() +} + +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 transcript_lines(&self) -> Vec> { + let mut lines: Vec> = vec![]; + for call in self.iter_calls() { + let cmd_display = strip_bash_lc_and_escape(&call.command); + for (i, part) in cmd_display.lines().enumerate() { + if i == 0 { + lines.push(vec!["$ ".magenta(), part.to_string().into()].into()); + } else { + lines.push(vec![" ".into(), part.to_string().into()].into()); + } + } + + if let Some(output) = call.output.as_ref() { + lines.extend(output.formatted_output.lines().map(ansi_escape_line)); + let duration = call + .duration + .map(format_duration) + .unwrap_or_else(|| "unknown".to_string()); + let mut result: Line = if output.exit_code == 0 { + Line::from("✓".green().bold()) + } else { + Line::from(vec![ + "✗".red().bold(), + format!(" ({})", output.exit_code).into(), + ]) + }; + result.push_span(format!(" • {duration}").dim()); + lines.push(result); + } + lines.push("".into()); + } + lines + } +} + +impl WidgetRef for &ExecCell { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 { + return; + } + let content_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: area.height, + }; + let lines = self.display_lines(area.width); + let max_rows = area.height as usize; + let rendered = if lines.len() > max_rows { + lines[lines.len() - max_rows..].to_vec() + } else { + lines + }; + + Paragraph::new(Text::from(rendered)) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } +} + +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()) + } else { + "•".bold() + }, + " ".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), + }; + let title = if self.is_active() { "Running" } else { "Ran" }; + + let mut header_line = + Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]); + let header_prefix_width = header_line.width(); + + let cmd_display = 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 raw_output_lines = output_lines( + Some(output), + OutputLinesParams { + only_err: false, + include_angle_pipe: false, + include_prefix: false, + }, + ); + let trimmed_output = + Self::truncate_lines_middle(&raw_output_lines, layout.output_max_lines); + + 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 trimmed_output { + push_owned_lines( + &word_wrap_line(&line, output_opts.clone()), + &mut wrapped_output, + ); + } + + if !wrapped_output.is_empty() { + lines.extend(prefix_lines( + wrapped_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) -> Vec> { + if max == 0 { + return Vec::new(); + } + if lines.len() <= max { + return lines.to_vec(); + } + if max == 1 { + return vec![Self::ellipsis_line(lines.len())]; + } + + 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 omitted = lines.len().saturating_sub(head + tail); + out.push(Self::ellipsis_line(omitted)); + + 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, +); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 3ae0d177b4..d95663d9fc 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,10 +1,14 @@ use crate::diff_render::create_diff_summary; +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; pub(crate) use crate::status::RateLimitSnapshotDisplay; pub(crate) use crate::status::new_status_output; pub(crate) use crate::status::rate_limit_snapshot_display; @@ -14,8 +18,6 @@ use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use base64::Engine; -use codex_ansi_escape::ansi_escape_line; -use codex_common::elapsed::format_duration; use codex_core::config::Config; use codex_core::config_types::ReasoningSummaryFormat; use codex_core::plan_tool::PlanItemArg; @@ -25,10 +27,8 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::parse_command::ParsedCommand; use image::DynamicImage; use image::ImageReader; -use itertools::Itertools; use mcp_types::EmbeddedResourceResource; use mcp_types::ResourceLink; use ratatui::prelude::*; @@ -49,14 +49,6 @@ use std::time::Instant; use tracing::error; use unicode_width::UnicodeWidthStr; -#[derive(Clone, Debug)] -pub(crate) struct CommandOutput { - pub(crate) exit_code: i32, - pub(crate) stdout: String, - pub(crate) stderr: String, - pub(crate) formatted_output: String, -} - #[derive(Clone, Debug)] pub(crate) enum PatchEventType { ApprovalRequest, @@ -266,357 +258,6 @@ impl HistoryCell for PatchHistoryCell { } } -#[derive(Debug, Clone)] -pub(crate) struct ExecCall { - pub(crate) call_id: String, - pub(crate) command: Vec, - pub(crate) parsed: Vec, - pub(crate) output: Option, - start_time: Option, - duration: Option, -} - -#[derive(Debug)] -pub(crate) struct ExecCell { - calls: Vec, -} -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 transcript_lines(&self) -> Vec> { - let mut lines: Vec> = vec![]; - for call in &self.calls { - let cmd_display = strip_bash_lc_and_escape(&call.command); - for (i, part) in cmd_display.lines().enumerate() { - if i == 0 { - lines.push(vec!["$ ".magenta(), part.to_string().into()].into()); - } else { - lines.push(vec![" ".into(), part.to_string().into()].into()); - } - } - - if let Some(output) = call.output.as_ref() { - lines.extend(output.formatted_output.lines().map(ansi_escape_line)); - let duration = call - .duration - .map(format_duration) - .unwrap_or_else(|| "unknown".to_string()); - let mut result: Line = if output.exit_code == 0 { - Line::from("✓".green().bold()) - } else { - Line::from(vec![ - "✗".red().bold(), - format!(" ({})", output.exit_code).into(), - ]) - }; - result.push_span(format!(" • {duration}").dim()); - lines.push(result); - } - lines.push("".into()); - } - lines - } -} - -impl ExecCell { - fn is_active(&self) -> bool { - self.calls.iter().any(|c| c.output.is_none()) - } - - fn exploring_display_lines(&self, width: u16) -> Vec> { - let mut out: Vec> = Vec::new(); - let active_start_time = self - .calls - .iter() - .find(|c| c.output.is_none()) - .and_then(|c| c.start_time); - out.push(Line::from(vec![ - if self.is_active() { - // Show an animated spinner while exploring - spinner(active_start_time) - } else { - "•".bold() - }, - " ".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(|c| matches!(c, ParsedCommand::Read { .. })) - { - while let Some(next) = calls.first() { - if next - .parsed - .iter() - .all(|c| matches!(c, ParsedCommand::Read { .. })) - { - call.parsed.extend(next.parsed.clone()); - calls.remove(0); - } else { - break; - } - } - } - let call_lines: Vec<(&str, Vec>)> = if call - .parsed - .iter() - .all(|c| matches!(c, ParsedCommand::Read { .. })) - { - let names = call - .parsed - .iter() - .map(|c| match c { - ParsedCommand::Read { name, .. } => name.clone(), - _ => unreachable!(), - }) - .unique(); - vec![( - "Read", - itertools::Itertools::intersperse( - names.into_iter().map(Into::into), - ", ".dim(), - ) - .collect(), - )] - } else { - let mut lines = Vec::new(); - for p in call.parsed { - match p { - ParsedCommand::Read { name, .. } => { - lines.push(("Read", vec![name.into()])); - } - ParsedCommand::ListFiles { cmd, path } => { - lines.push(("List", vec![path.unwrap_or(cmd).into()])); - } - ParsedCommand::Search { cmd, query, path } => { - lines.push(( - "Search", - match (query, path) { - (Some(q), Some(p)) => { - vec![q.into(), " in ".dim(), p.into()] - } - (Some(q), None) => vec![q.into()], - _ => vec![cmd.into()], - }, - )); - } - ParsedCommand::Unknown { cmd } => { - lines.push(("Run", vec![cmd.into()])); - } - } - } - lines - }; - for (title, line) in call_lines { - let 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> { - use textwrap::Options as TwOptions; - - let mut lines: Vec> = Vec::new(); - let [call] = &self.calls.as_slice() else { - panic!("Expected exactly one call in a command display cell"); - }; - let success = call.output.as_ref().map(|o| o.exit_code == 0); - let bullet = match success { - Some(true) => "•".green().bold(), - Some(false) => "•".red().bold(), - None => spinner(call.start_time), - }; - let title = if self.is_active() { "Running" } else { "Ran" }; - let cmd_display = strip_bash_lc_and_escape(&call.command); - - // If the command fits on the same line as the header at the current width, - // show a single compact line: "• Ran ". Use the width of - // "• Running " (including trailing space) as the reserved prefix width. - // If the command contains newlines, always use the multi-line variant. - let reserved = "• Running ".width(); - - let mut body_lines: Vec> = Vec::new(); - - let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd_display); - - if highlighted_lines.len() == 1 - && highlighted_lines[0].width() < (width as usize).saturating_sub(reserved) - { - let mut line = Line::from(vec![bullet, " ".into(), title.bold(), " ".into()]); - line.extend(highlighted_lines[0].clone()); - lines.push(line); - } else { - lines.push(vec![bullet, " ".into(), title.bold()].into()); - - for hl_line in highlighted_lines.iter() { - let opts = crate::wrapping::RtOptions::new((width as usize).saturating_sub(4)) - .initial_indent("".into()) - .subsequent_indent(" ".into()) - // Hyphenation likes to break words on hyphens, which is bad for bash scripts --because-of-flags. - .word_splitter(textwrap::WordSplitter::NoHyphenation); - let wrapped_borrowed = crate::wrapping::word_wrap_line(hl_line, opts); - body_lines.extend(wrapped_borrowed.iter().map(|l| line_to_static(l))); - } - } - if let Some(output) = call.output.as_ref() - && output.exit_code != 0 - { - let out = output_lines( - Some(output), - OutputLinesParams { - only_err: false, - include_angle_pipe: false, - include_prefix: false, - }, - ) - .into_iter() - .join("\n"); - if !out.trim().is_empty() { - // Wrap the output. - for line in out.lines() { - let wrapped = textwrap::wrap(line, TwOptions::new(width as usize - 4)); - body_lines.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim()))); - } - } - } - lines.extend(prefix_lines(body_lines, " └ ".dim(), " ".into())); - lines - } -} - -impl WidgetRef for &ExecCell { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 { - return; - } - let content_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: area.height, - }; - let lines = self.display_lines(area.width); - let max_rows = area.height as usize; - let rendered = if lines.len() > max_rows { - // Keep the last `max_rows` lines in original order - lines[lines.len() - max_rows..].to_vec() - } else { - lines - }; - - Paragraph::new(Text::from(rendered)) - .wrap(Wrap { trim: false }) - .render(content_area, buf); - } -} - -impl ExecCell { - 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, - stdout: String::new(), - stderr: String::new(), - formatted_output: String::new(), - }); - } - } - } - - pub(crate) fn new(call: ExecCall) -> Self { - ExecCell { calls: vec![call] } - } - - fn is_exploring_call(call: &ExecCall) -> bool { - !call.parsed.is_empty() - && call.parsed.iter().all(|p| { - matches!( - p, - ParsedCommand::Read { .. } - | ParsedCommand::ListFiles { .. } - | ParsedCommand::Search { .. } - ) - }) - } - - fn is_exploring_cell(&self) -> bool { - self.calls.iter().all(Self::is_exploring_call) - } - - pub(crate) fn with_added_call( - &self, - call_id: String, - command: Vec, - parsed: Vec, - ) -> Option { - let call = ExecCall { - call_id, - command, - parsed, - output: None, - start_time: Some(Instant::now()), - duration: None, - }; - if self.is_exploring_cell() && Self::is_exploring_call(&call) { - Some(Self { - calls: [self.calls.clone(), vec![call]].concat(), - }) - } else { - None - } - } - - pub(crate) fn complete_call( - &mut self, - call_id: &str, - output: CommandOutput, - duration: Duration, - ) { - if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { - call.output = Some(output); - call.duration = Some(duration); - call.start_time = None; - } - } - - pub(crate) fn should_flush(&self) -> bool { - !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) - } -} - #[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, @@ -627,7 +268,6 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput { } } -const TOOL_CALL_MAX_LINES: usize = 5; 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 { @@ -783,21 +423,6 @@ pub(crate) fn new_user_approval_decision(lines: Vec>) -> PlainHist PlainHistoryCell { lines } } -pub(crate) fn new_active_exec_command( - call_id: String, - command: Vec, - parsed: Vec, -) -> ExecCell { - ExecCell::new(ExecCall { - call_id, - command, - parsed, - output: None, - start_time: Some(Instant::now()), - duration: None, - }) -} - #[derive(Debug)] struct SessionHeaderHistoryCell { version: &'static str, @@ -1116,15 +741,6 @@ impl WidgetRef for &McpToolCallCell { } } -fn spinner(start_time: Option) -> Span<'static> { - const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let idx = start_time - .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) - .unwrap_or(0); - let ch = FRAMES[idx]; - ch.to_string().into() -} - pub(crate) fn new_active_mcp_tool_call( call_id: String, invocation: McpInvocation, @@ -1444,79 +1060,6 @@ pub(crate) fn new_reasoning_summary_block( Box::new(new_reasoning_block(full_reasoning_buffer, config)) } -struct OutputLinesParams { - only_err: bool, - include_angle_pipe: bool, - include_prefix: bool, -} - -fn output_lines(output: Option<&CommandOutput>, params: OutputLinesParams) -> Vec> { - let OutputLinesParams { - only_err, - include_angle_pipe, - include_prefix, - } = params; - let CommandOutput { - exit_code, - stdout, - stderr, - .. - } = match output { - Some(output) if only_err && output.exit_code == 0 => return vec![], - Some(output) => output, - None => return vec![], - }; - - let src = if *exit_code == 0 { stdout } else { stderr }; - let lines: Vec<&str> = src.lines().collect(); - let total = lines.len(); - let limit = TOOL_CALL_MAX_LINES; - - let mut out = Vec::new(); - - let head_end = total.min(limit); - for (i, raw) in lines[..head_end].iter().enumerate() { - let mut line = ansi_escape_line(raw); - let prefix = if !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); - } - - // If we will ellipsize less than the limit, just show it. - let show_ellipsis = total > 2 * limit; - if show_ellipsis { - let omitted = total - 2 * limit; - out.push(format!("… +{omitted} lines").into()); - } - - let tail_start = if show_ellipsis { - total - 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); - } - - out -} - fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { let args_str = invocation .arguments @@ -1541,9 +1084,13 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { #[cfg(test)] mod tests { use super::*; + use crate::exec_cell::CommandOutput; + use crate::exec_cell::ExecCall; + use crate::exec_cell::ExecCell; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; + use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; use serde_json::json; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8f51bc9d84..998ecb7434 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -41,6 +41,7 @@ mod cli; mod clipboard_paste; pub mod custom_terminal; mod diff_render; +mod exec_cell; mod exec_command; mod file_search; mod frames; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index b92939a1a5..ec9652ce55 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -556,7 +556,7 @@ mod tests { use std::sync::Arc; use std::time::Duration; - use crate::history_cell::CommandOutput; + use crate::exec_cell::CommandOutput; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::history_cell::new_patch_event; @@ -687,7 +687,7 @@ mod tests { ])); cells.push(apply_end_cell); - let mut exec_cell = crate::history_cell::new_active_exec_command( + let mut exec_cell = crate::exec_cell::new_active_exec_command( "exec-1".into(), vec!["bash".into(), "-lc".into(), "ls".into()], vec![ParsedCommand::Unknown { cmd: "ls".into() }], diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap index 0964b64aec..05500c6260 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap @@ -1,10 +1,8 @@ --- source: tui/src/history_cell.rs -assertion_line: 1942 expression: rendered --- -• Ran - └ first_token_is_long_enou - gh_to_wrap - second_token_is_also_lon - g_enough_to_wrap +• Ran first_token_is_long_en + │ ough_to_wrap + │ second_token_is_also_lon + │ … +1 lines diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap index 7dc9d5722f..33644d4960 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap @@ -2,6 +2,5 @@ source: tui/src/history_cell.rs expression: rendered --- -• Ran - └ echo one - echo two +• Ran echo one + │ echo two diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap index de36cd92af..acd5b64b5b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap @@ -1,10 +1,7 @@ --- source: tui/src/history_cell.rs -assertion_line: 1797 expression: rendered --- -• Ran - └ set -o pipefail - cargo test - --all-features - --quiet +• Ran set -o pipefail + │ cargo test + │ --all-features --quiet diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap index 4a0ec93876..0c7b5ee840 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap @@ -2,13 +2,11 @@ source: tui/src/history_cell.rs expression: rendered --- -• Ran - └ echo - this_is_a_very_long_ - single_token_that_wi - ll_wrap_across_the_a - vailable_width - error: first line on +• Ran echo + │ this_is_a_very_long_si + │ ngle_token_that_will_w + │ … +2 lines + └ error: first line on stderr error: second line on stderr diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap index 5ca1330757..27623ae1f1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap @@ -1,9 +1,7 @@ --- source: tui/src/history_cell.rs -assertion_line: 1869 expression: rendered --- -• Ran - └ a_very_long_token_wi - thout_spaces_to_ - force_wrapping +• Ran a_very_long_token_ + │ without_spaces_to_ + │ force_wrapping diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap index ccba8c22ca..ae7ee15187 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap @@ -5,11 +5,6 @@ expression: rendered • Ran seq 1 10 1>&2 && false └ 1 2 - 3 - 4 - 5 - 6 - 7 - 8 + … +6 lines 9 10 From 8797145678cb9c2b3650a8a46203b68292b7e783 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 26 Sep 2025 16:24:27 +0200 Subject: [PATCH 05/31] fix: token usage for compaction (#4281) Emit token usage update when draining compaction --- codex-rs/core/src/codex/compact.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 8689bd020c..136e68e401 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -93,7 +93,8 @@ async fn run_compact_task_inner( sess.persist_rollout_items(&[rollout_item]).await; loop { - let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await; + let attempt_result = + drain_to_completed(&sess, turn_context.as_ref(), &sub_id, &prompt).await; match attempt_result { Ok(()) => { @@ -229,6 +230,7 @@ pub(crate) fn build_compacted_history( async fn drain_to_completed( sess: &Session, turn_context: &TurnContext, + sub_id: &str, prompt: &Prompt, ) -> CodexResult<()> { let mut stream = turn_context.client.clone().stream(prompt).await?; @@ -244,7 +246,12 @@ async fn drain_to_completed( Ok(ResponseEvent::OutputItemDone(item)) => { sess.record_into_history(std::slice::from_ref(&item)).await; } - Ok(ResponseEvent::Completed { .. }) => { + Ok(ResponseEvent::RateLimits(snapshot)) => { + sess.update_rate_limits(sub_id, snapshot).await; + } + Ok(ResponseEvent::Completed { token_usage, .. }) => { + sess.update_token_usage_info(sub_id, turn_context, token_usage.as_ref()) + .await; return Ok(()); } Ok(_) => continue, From c5494815139d564c72d819aa7ea652373e6499d0 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 26 Sep 2025 08:19:00 -0700 Subject: [PATCH 06/31] feat: introduce responses-api-proxy (#4246) Details are in `responses-api-proxy/README.md`, but the key contribution of this PR is a new subcommand, `codex responses-api-proxy`, which reads the auth token for use with the OpenAI Responses API from `stdin` at startup and then proxies `POST` requests to `/v1/responses` over to `https://api.openai.com/v1/responses`, injecting the auth token as part of the `Authorization` header. The expectation is that `codex responses-api-proxy` is launched by a privileged user who has access to the auth token so that it can be used by unprivileged users of the Codex CLI on the same host. If the client only has one user account with `sudo`, one option is to: - run `sudo codex responses-api-proxy --http-shutdown --server-info /tmp/server-info.json` to start the server - record the port written to `/tmp/server-info.json` - relinquish their `sudo` privileges (which is irreversible!) like so: ``` sudo deluser $USER sudo || sudo gpasswd -d $USER sudo || true ``` - use `codex` with the proxy (see `README.md`) - when done, make a `GET` request to the server using the `PORT` from `server-info.json` to shut it down: ```shell curl --fail --silent --show-error "http://127.0.0.1:$PORT/shutdown" ``` To protect the auth token, we: - allocate a 1024 byte buffer on the stack and write `"Bearer "` into it to start - we then read from `stdin`, copying to the contents into the buffer after the prefix - after verifying the input looks good, we create a `String` from that buffer (so the data is now on the heap) - we zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler - we invoke `.leak()` on the `String` so we can treat its contents as a `&'static str`, as it will live for the rest of the processs - on UNIX, we `mlock(2)` the memory backing the `&'static str` - when using the `&'static str` when building an HTTP request, we use `HeaderValue::from_static()` to avoid copying the `&str` - we also invoke `.set_sensitive(true)` on the `HeaderValue`, which in theory indicates to other parts of the HTTP stack that the header should be treated with "special care" to avoid leakage: https://github.com/hyperium/http/blob/439d1c50d71e3be3204b6c4a1bf2255ed78e1f93/src/header/value.rs#L346-L376 --- codex-rs/Cargo.lock | 137 +++++++++++- codex-rs/Cargo.toml | 3 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 11 + codex-rs/responses-api-proxy/Cargo.toml | 27 +++ codex-rs/responses-api-proxy/README.md | 53 +++++ codex-rs/responses-api-proxy/src/lib.rs | 202 ++++++++++++++++++ codex-rs/responses-api-proxy/src/main.rs | 14 ++ .../responses-api-proxy/src/read_api_key.rs | 185 ++++++++++++++++ 9 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 codex-rs/responses-api-proxy/Cargo.toml create mode 100644 codex-rs/responses-api-proxy/README.md create mode 100644 codex-rs/responses-api-proxy/src/lib.rs create mode 100644 codex-rs/responses-api-proxy/src/main.rs create mode 100644 codex-rs/responses-api-proxy/src/read_api_key.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a54222a835..a50a82b438 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -488,6 +488,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -640,6 +646,7 @@ dependencies = [ "codex-mcp-server", "codex-protocol", "codex-protocol-ts", + "codex-responses-api-proxy", "codex-tui", "ctor 0.5.0", "libc", @@ -926,6 +933,22 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "codex-responses-api-proxy" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-arg0", + "libc", + "reqwest", + "serde", + "serde_json", + "tiny_http", + "tokio", + "zeroize", +] + [[package]] name = "codex-tui" version = "0.0.0" @@ -1949,8 +1972,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1960,9 +1985,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -2159,6 +2186,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2759,6 +2787,12 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lsp-types" version = "0.94.1" @@ -2938,7 +2972,7 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.9.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", "libc", ] @@ -3495,6 +3529,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.40" @@ -3687,6 +3776,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3694,6 +3785,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3703,6 +3795,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -3725,6 +3818,12 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "0.38.44" @@ -3758,6 +3857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3770,6 +3870,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -4684,6 +4785,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.47.1" @@ -5296,6 +5412,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webbrowser" version = "1.0.5" @@ -5312,6 +5438,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.10" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 237f5ea05c..33b1b303f0 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -18,6 +18,7 @@ members = [ "ollama", "protocol", "protocol-ts", + "responses-api-proxy", "tui", "utils/readiness", ] @@ -49,6 +50,7 @@ codex-mcp-server = { path = "mcp-server" } codex-ollama = { path = "ollama" } codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } +codex-responses-api-proxy = { path = "responses-api-proxy" } codex-tui = { path = "tui" } codex-utils-readiness = { path = "utils/readiness" } core_test_support = { path = "core/tests/common" } @@ -152,6 +154,7 @@ webbrowser = "1.0" which = "6" wildmatch = "2.5.0" wiremock = "0.6" +zeroize = "1.8.1" [workspace.lints] rust = {} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 44217891a1..e61285c822 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -27,6 +27,7 @@ codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-protocol = { workspace = true } codex-protocol-ts = { workspace = true } +codex-responses-api-proxy = { workspace = true } codex-tui = { workspace = true } ctor = { workspace = true } owo-colors = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index b1e9601c8c..871966b0a7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; @@ -14,6 +15,7 @@ use codex_cli::login::run_logout; use codex_cli::proto; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; +use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use owo_colors::OwoColorize; @@ -86,6 +88,10 @@ enum Subcommand { /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), + + /// Internal: run the responses API proxy. + #[clap(hide = true)] + ResponsesApiProxy(ResponsesApiProxyArgs), } #[derive(Debug, Parser)] @@ -341,6 +347,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } + Some(Subcommand::ResponsesApiProxy(args)) => { + tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) + .await + .context("responses-api-proxy blocking task panicked")??; + } } Ok(()) diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml new file mode 100644 index 0000000000..b66473b195 --- /dev/null +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -0,0 +1,27 @@ +[package] +edition = "2024" +name = "codex-responses-api-proxy" +version = { workspace = true } + +[lib] +name = "codex_responses_api_proxy" +path = "src/lib.rs" + +[[bin]] +name = "responses-api-proxy" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-arg0 = { workspace = true } +libc = { workspace = true } +reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tiny_http = { workspace = true } +tokio = { workspace = true } +zeroize = { workspace = true } diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md new file mode 100644 index 0000000000..4f5304a553 --- /dev/null +++ b/codex-rs/responses-api-proxy/README.md @@ -0,0 +1,53 @@ +# codex-responses-api-proxy + +A strict HTTP proxy that only forwards `POST` requests to `/v1/responses` to the OpenAI API (`https://api.openai.com`), injecting the `Authorization: Bearer $OPENAI_API_KEY` header. Everything else is rejected with `403 Forbidden`. + +## Expected Usage + +**IMPORTANT:** This is designed to be used with `CODEX_SECURE_MODE=1` so that an unprivileged user cannot inspect or tamper with this process. Though if `--http-shutdown` is specified, an unprivileged user _can_ shutdown the server. + +A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server: + +```shell +printenv OPENAI_API_KEY | CODEX_SECURE_MODE=1 codex responses-api-proxy --http-shutdown --server-info /tmp/server-info.json +``` + +A non-privileged user would then run Codex as follows, specifying the `model_provider` dynamically: + +```shell +PROXY_PORT=$(jq .port /tmp/server-info.json) +PROXY_BASE_URL="http://127.0.0.1:${PROXY_PORT}" +codex exec -c "model_providers.openai-proxy={ name = 'OpenAI Proxy', base_url = '${PROXY_BASE_URL}/v1', wire_api='responses' }" \ + -c model_provider="openai-proxy" \ + 'Your prompt here' +``` + +When the unprivileged user was finished, they could shutdown the server using `curl` (since `kill -9` is not an option): + +```shell +curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" +``` + +## Behavior + +- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex responses-api-proxy`). +- Formats the header value as `Bearer ` and attempts to `mlock(2)` the memory holding that header so it is not swapped to disk. +- Listens on the provided port or an ephemeral port if `--port` is not specified. +- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer ` set. All original request headers (except any incoming `Authorization`) are forwarded upstream. For other requests, it responds with `403`. +- Optionally writes a single-line JSON file with server info, currently `{ "port": }`. +- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., root) to start the proxy and another unprivileged user on the host to shut it down. + +## CLI + +``` +responses-api-proxy [--port ] [--server-info ] [--http-shutdown] +``` + +- `--port `: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen. +- `--server-info `: If set, the proxy writes a single line of JSON with `{ "port": }` once listening. +- `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`. + +## Notes + +- Only `POST /v1/responses` is permitted. No query strings are allowed. +- All request headers are forwarded to the upstream call (aside from overriding `Authorization`). Response status and content-type are mirrored from upstream. diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs new file mode 100644 index 0000000000..600a8d6c7b --- /dev/null +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -0,0 +1,202 @@ +use std::fs::File; +use std::fs::{self}; +use std::io::Write; +use std::net::SocketAddr; +use std::net::TcpListener; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use clap::Parser; +use reqwest::blocking::Client; +use reqwest::header::AUTHORIZATION; +use reqwest::header::HOST; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use serde::Serialize; +use tiny_http::Header; +use tiny_http::Method; +use tiny_http::Request; +use tiny_http::Response; +use tiny_http::Server; +use tiny_http::StatusCode; + +mod read_api_key; +use read_api_key::read_auth_header_from_stdin; + +/// CLI arguments for the proxy. +#[derive(Debug, Clone, Parser)] +#[command(name = "responses-api-proxy", about = "Minimal OpenAI responses proxy")] +pub struct Args { + /// Port to listen on. If not set, an ephemeral port is used. + #[arg(long)] + pub port: Option, + + /// Path to a JSON file to write startup info (single line). Includes {"port": }. + #[arg(long, value_name = "FILE")] + pub server_info: Option, + + /// Enable HTTP shutdown endpoint at GET /shutdown + #[arg(long)] + pub http_shutdown: bool, +} + +#[derive(Serialize)] +struct ServerInfo { + port: u16, +} + +/// Entry point for the library main, for parity with other crates. +pub fn run_main(args: Args) -> Result<()> { + let auth_header = read_auth_header_from_stdin()?; + + let (listener, bound_addr) = bind_listener(args.port)?; + if let Some(path) = args.server_info.as_ref() { + write_server_info(path, bound_addr.port())?; + } + let server = Server::from_listener(listener, None) + .map_err(|err| anyhow!("creating HTTP server: {err}"))?; + let client = Arc::new( + Client::builder() + .build() + .context("building reqwest client")?, + ); + + eprintln!("responses-api-proxy listening on {bound_addr}"); + + let http_shutdown = args.http_shutdown; + for request in server.incoming_requests() { + let client = client.clone(); + std::thread::spawn(move || { + if http_shutdown && request.method() == &Method::Get && request.url() == "/shutdown" { + let _ = request.respond(Response::new_empty(StatusCode(200))); + std::process::exit(0); + } + + if let Err(e) = forward_request(&client, auth_header, request) { + eprintln!("forwarding error: {e}"); + } + }); + } + + Err(anyhow!("server stopped unexpectedly")) +} + +fn bind_listener(port: Option) -> Result<(TcpListener, SocketAddr)> { + let addr = SocketAddr::from(([127, 0, 0, 1], port.unwrap_or(0))); + let listener = TcpListener::bind(addr).with_context(|| format!("failed to bind {addr}"))?; + let bound = listener.local_addr().context("failed to read local_addr")?; + Ok((listener, bound)) +} + +fn write_server_info(path: &Path, port: u16) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + let parent_display = parent.display(); + fs::create_dir_all(parent).with_context(|| format!("create_dir_all {parent_display}"))?; + } + let info = ServerInfo { port }; + let data = serde_json::to_vec(&info).context("serialize startup info")?; + let p = path.display(); + let mut f = File::create(path).with_context(|| format!("create {p}"))?; + f.write_all(&data).with_context(|| format!("write {p}"))?; + f.write_all(b"\n").with_context(|| format!("newline {p}"))?; + Ok(()) +} + +fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) -> Result<()> { + // Only allow POST /v1/responses exactly, no query string. + let method = req.method().clone(); + let url_path = req.url().to_string(); + let allow = method == Method::Post && url_path == "/v1/responses"; + + if !allow { + let resp = Response::new_empty(StatusCode(403)); + let _ = req.respond(resp); + return Ok(()); + } + + // Read request body + let mut body = Vec::new(); + let mut reader = req.as_reader(); + std::io::Read::read_to_end(&mut reader, &mut body)?; + + // Build headers for upstream, forwarding everything from the incoming + // request except Authorization (we replace it below). + let mut headers = HeaderMap::new(); + for header in req.headers() { + let name_ascii = header.field.as_str(); + let lower = name_ascii.to_ascii_lowercase(); + if lower.as_str() == "authorization" || lower.as_str() == "host" { + continue; + } + + let header_name = match HeaderName::from_bytes(lower.as_bytes()) { + Ok(name) => name, + Err(_) => continue, + }; + if let Ok(value) = HeaderValue::from_bytes(header.value.as_bytes()) { + headers.append(header_name, value); + } + } + + // As part of our effort to to keep `auth_header` secret, we use a + // combination of `from_static()` and `set_sensitive(true)`. + let mut auth_header_value = HeaderValue::from_static(auth_header); + auth_header_value.set_sensitive(true); + headers.insert(AUTHORIZATION, auth_header_value); + + headers.insert(HOST, HeaderValue::from_static("api.openai.com")); + + let upstream = "https://api.openai.com/v1/responses"; + let upstream_resp = client + .post(upstream) + .headers(headers) + .body(body) + .send() + .context("forwarding request to upstream")?; + + // We have to create an adapter between a `reqwest::blocking::Response` + // and a `tiny_http::Response`. Fortunately, `reqwest::blocking::Response` + // implements `Read`, so we can use it directly as the body of the + // `tiny_http::Response`. + let status = upstream_resp.status(); + let mut response_headers = Vec::new(); + for (name, value) in upstream_resp.headers().iter() { + // Skip headers that tiny_http manages itself. + if matches!( + name.as_str(), + "content-length" | "transfer-encoding" | "connection" | "trailer" | "upgrade" + ) { + continue; + } + + if let Ok(header) = Header::from_bytes(name.as_str().as_bytes(), value.as_bytes()) { + response_headers.push(header); + } + } + + let content_length = upstream_resp.content_length().and_then(|len| { + if len <= usize::MAX as u64 { + Some(len as usize) + } else { + None + } + }); + + let response = Response::new( + StatusCode(status.as_u16()), + response_headers, + upstream_resp, + content_length, + None, + ); + + let _ = req.respond(response); + Ok(()) +} diff --git a/codex-rs/responses-api-proxy/src/main.rs b/codex-rs/responses-api-proxy/src/main.rs new file mode 100644 index 0000000000..eaa534d981 --- /dev/null +++ b/codex-rs/responses-api-proxy/src/main.rs @@ -0,0 +1,14 @@ +use anyhow::Context; +use clap::Parser; +use codex_arg0::arg0_dispatch_or_else; +use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; + +pub fn main() -> anyhow::Result<()> { + arg0_dispatch_or_else(|_codex_linux_sandbox_exe| async move { + let args = ResponsesApiProxyArgs::parse(); + tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) + .await + .context("responses-api-proxy blocking task panicked")??; + Ok(()) + }) +} diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs new file mode 100644 index 0000000000..8ffad2aab3 --- /dev/null +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -0,0 +1,185 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use std::io::Read; +use zeroize::Zeroize; + +/// Use a generous buffer size to avoid truncation and to allow for longer API +/// keys in the future. +const BUFFER_SIZE: usize = 1024; +const AUTH_HEADER_PREFIX: &[u8] = b"Bearer "; + +/// Reads the auth token from stdin and returns a static `Authorization` header +/// value with the auth token used with `Bearer`. The header value is returned +/// as a `&'static str` whose bytes are locked in memory to avoid accidental +/// exposure. +pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> { + read_auth_header_with(|buffer| std::io::stdin().read(buffer)) +} + +fn read_auth_header_with(read_fn: F) -> Result<&'static str> +where + F: FnOnce(&mut [u8]) -> std::io::Result, +{ + // TAKE CARE WHEN MODIFYING THIS CODE!!! + // + // This function goes to great lengths to avoid leaving the API key in + // memory longer than necessary and to avoid copying it around. We read + // directly into a stack buffer so the only heap allocation should be the + // one to create the String (with the exact size) for the header value, + // which we then immediately protect with mlock(2). + let mut buf = [0u8; BUFFER_SIZE]; + buf[..AUTH_HEADER_PREFIX.len()].copy_from_slice(AUTH_HEADER_PREFIX); + + let read = read_fn(&mut buf[AUTH_HEADER_PREFIX.len()..]).inspect_err(|_err| { + buf.zeroize(); + })?; + + if read == buf.len() - AUTH_HEADER_PREFIX.len() { + buf.zeroize(); + return Err(anyhow!( + "OPENAI_API_KEY is too large to fit in the 512-byte buffer" + )); + } + + let mut total = AUTH_HEADER_PREFIX.len() + read; + while total > AUTH_HEADER_PREFIX.len() && (buf[total - 1] == b'\n' || buf[total - 1] == b'\r') { + total -= 1; + } + + if total == AUTH_HEADER_PREFIX.len() { + buf.zeroize(); + return Err(anyhow!( + "OPENAI_API_KEY must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" + )); + } + + let header_str = match std::str::from_utf8(&buf[..total]) { + Ok(value) => value, + Err(err) => { + buf.zeroize(); + return Err(err).context("reading Authorization header from stdin as UTF-8"); + } + }; + + let header_value = String::from(header_str); + buf.zeroize(); + + let leaked: &'static mut str = header_value.leak(); + mlock_str(leaked); + + Ok(leaked) +} + +#[cfg(unix)] +fn mlock_str(value: &str) { + use libc::_SC_PAGESIZE; + use libc::c_void; + use libc::mlock; + use libc::sysconf; + + if value.is_empty() { + return; + } + + let page_size = unsafe { sysconf(_SC_PAGESIZE) }; + if page_size <= 0 { + return; + } + let page_size = page_size as usize; + if page_size == 0 { + return; + } + + let addr = value.as_ptr() as usize; + let len = value.len(); + let start = addr & !(page_size - 1); + let addr_end = match addr.checked_add(len) { + Some(v) => match v.checked_add(page_size - 1) { + Some(total) => total, + None => return, + }, + None => return, + }; + let end = addr_end & !(page_size - 1); + let size = end.saturating_sub(start); + if size == 0 { + return; + } + + let _ = unsafe { mlock(start as *const c_void, size) }; +} + +#[cfg(not(unix))] +fn mlock_str(_value: &str) {} + +#[cfg(test)] +mod tests { + use super::*; + use std::io; + + #[test] + fn reads_key_with_no_newlines() { + let result = read_auth_header_with(|buf| { + let data = b"sk-abc123"; + buf[..data.len()].copy_from_slice(data); + Ok(data.len()) + }) + .unwrap(); + + assert_eq!(result, "Bearer sk-abc123"); + } + + #[test] + fn reads_key_and_trims_newlines() { + let result = read_auth_header_with(|buf| { + let data = b"sk-abc123\r\n"; + buf[..data.len()].copy_from_slice(data); + Ok(data.len()) + }) + .unwrap(); + + assert_eq!(result, "Bearer sk-abc123"); + } + + #[test] + fn errors_when_no_input_provided() { + let err = read_auth_header_with(|_| Ok(0)).unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("must be provided")); + } + + #[test] + fn errors_when_buffer_filled() { + let err = read_auth_header_with(|buf| { + let data = vec![b'a'; BUFFER_SIZE - AUTH_HEADER_PREFIX.len()]; + buf[..data.len()].copy_from_slice(&data); + Ok(data.len()) + }) + .unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("too large")); + } + + #[test] + fn propagates_io_error() { + let err = read_auth_header_with(|_| Err(io::Error::other("boom"))).unwrap_err(); + + let io_error = err.downcast_ref::().unwrap(); + assert_eq!(io_error.kind(), io::ErrorKind::Other); + assert_eq!(io_error.to_string(), "boom"); + } + + #[test] + fn errors_on_invalid_utf8() { + let err = read_auth_header_with(|buf| { + let data = b"sk-abc\xff"; + buf[..data.len()].copy_from_slice(data); + Ok(data.len()) + }) + .unwrap_err(); + + let message = format!("{err:#}"); + assert!(message.contains("UTF-8")); + } +} From ea095e30c1ca6607b7b35b0fcc742c257b241d6d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 26 Sep 2025 09:35:47 -0700 Subject: [PATCH 07/31] Add todo-list tool support (#4255) Adds a 1-per-turn todo-list item and item.updated event ```jsonl {"type":"item.started","item":{"id":"item_6","item_type":"todo_list","items":[{"text":"Record initial two-step plan now","completed":false},{"text":"Update progress to next step","completed":false}]}} {"type":"item.updated","item":{"id":"item_6","item_type":"todo_list","items":[{"text":"Record initial two-step plan now","completed":true},{"text":"Update progress to next step","completed":false}]}} {"type":"item.completed","item":{"id":"item_6","item_type":"todo_list","items":[{"text":"Record initial two-step plan now","completed":true},{"text":"Update progress to next step","completed":false}]}} ``` --- codex-rs/exec/src/exec_events.rs | 22 ++- ...mental_event_processor_with_json_output.rs | 65 +++++++ .../tests/event_processor_with_json_output.rs | 168 ++++++++++++++++++ 3 files changed, 252 insertions(+), 3 deletions(-) diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 82f15be320..38a431869b 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -10,30 +10,34 @@ pub enum ConversationEvent { SessionCreated(SessionCreatedEvent), #[serde(rename = "item.started")] ItemStarted(ItemStartedEvent), + #[serde(rename = "item.updated")] + ItemUpdated(ItemUpdatedEvent), #[serde(rename = "item.completed")] ItemCompleted(ItemCompletedEvent), #[serde(rename = "error")] Error(ConversationErrorEvent), } -/// Payload describing a newly created conversation item. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub struct SessionCreatedEvent { pub session_id: String, } -/// Payload describing the start of an existing conversation item. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub struct ItemStartedEvent { pub item: ConversationItem, } -/// Payload describing the completion of an existing conversation item. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub struct ItemCompletedEvent { pub item: ConversationItem, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +pub struct ItemUpdatedEvent { + pub item: ConversationItem, +} + /// Fatal error emitted by the stream. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub struct ConversationErrorEvent { @@ -58,6 +62,7 @@ pub enum ConversationItemDetails { FileChange(FileChangeItem), McpToolCall(McpToolCallItem), WebSearch(WebSearchItem), + TodoList(TodoListItem), Error(ErrorItem), } @@ -153,3 +158,14 @@ pub struct WebSearchItem { pub struct ErrorItem { pub message: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +pub struct TodoItem { + pub text: String, + pub completed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +pub struct TodoListItem { + pub items: Vec, +} diff --git a/codex-rs/exec/src/experimental_event_processor_with_json_output.rs b/codex-rs/exec/src/experimental_event_processor_with_json_output.rs index ea4ba02240..a2eddc178b 100644 --- a/codex-rs/exec/src/experimental_event_processor_with_json_output.rs +++ b/codex-rs/exec/src/experimental_event_processor_with_json_output.rs @@ -16,11 +16,16 @@ use crate::exec_events::FileChangeItem; use crate::exec_events::FileUpdateChange; use crate::exec_events::ItemCompletedEvent; use crate::exec_events::ItemStartedEvent; +use crate::exec_events::ItemUpdatedEvent; use crate::exec_events::PatchApplyStatus; use crate::exec_events::PatchChangeKind; use crate::exec_events::ReasoningItem; use crate::exec_events::SessionCreatedEvent; +use crate::exec_events::TodoItem; +use crate::exec_events::TodoListItem; use codex_core::config::Config; +use codex_core::plan_tool::StepStatus; +use codex_core::plan_tool::UpdatePlanArgs; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningEvent; use codex_core::protocol::Event; @@ -41,6 +46,8 @@ pub struct ExperimentalEventProcessorWithJsonOutput { // Tracks running commands by call_id, including the associated item id. running_commands: HashMap, running_patch_applies: HashMap, + // Tracks the todo list for the current turn (at most one per turn). + running_todo_list: Option, } #[derive(Debug, Clone)] @@ -49,6 +56,12 @@ struct RunningCommand { item_id: String, } +#[derive(Debug, Clone)] +struct RunningTodoList { + item_id: String, + items: Vec, +} + impl ExperimentalEventProcessorWithJsonOutput { pub fn new(last_message_path: Option) -> Self { Self { @@ -56,6 +69,7 @@ impl ExperimentalEventProcessorWithJsonOutput { next_event_id: AtomicU64::new(0), running_commands: HashMap::new(), running_patch_applies: HashMap::new(), + running_todo_list: None, } } @@ -74,6 +88,8 @@ impl ExperimentalEventProcessorWithJsonOutput { EventMsg::StreamError(ev) => vec![ConversationEvent::Error(ConversationErrorEvent { message: ev.message.clone(), })], + EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev), + EventMsg::TaskComplete(_) => self.handle_task_complete(), _ => Vec::new(), } } @@ -232,6 +248,55 @@ impl ExperimentalEventProcessorWithJsonOutput { item, })] } + + fn todo_items_from_plan(&self, args: &UpdatePlanArgs) -> Vec { + args.plan + .iter() + .map(|p| TodoItem { + text: p.step.clone(), + completed: matches!(p.status, StepStatus::Completed), + }) + .collect() + } + + fn handle_plan_update(&mut self, args: &UpdatePlanArgs) -> Vec { + let items = self.todo_items_from_plan(args); + + if let Some(running) = &mut self.running_todo_list { + running.items = items.clone(); + let item = ConversationItem { + id: running.item_id.clone(), + details: ConversationItemDetails::TodoList(TodoListItem { items }), + }; + return vec![ConversationEvent::ItemUpdated(ItemUpdatedEvent { item })]; + } + + let item_id = self.get_next_item_id(); + self.running_todo_list = Some(RunningTodoList { + item_id: item_id.clone(), + items: items.clone(), + }); + let item = ConversationItem { + id: item_id, + details: ConversationItemDetails::TodoList(TodoListItem { items }), + }; + vec![ConversationEvent::ItemStarted(ItemStartedEvent { item })] + } + + fn handle_task_complete(&mut self) -> Vec { + if let Some(running) = self.running_todo_list.take() { + let item = ConversationItem { + id: running.item_id, + details: ConversationItemDetails::TodoList(TodoListItem { + items: running.items, + }), + }; + return vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { + item, + })]; + } + Vec::new() + } } impl EventProcessor for ExperimentalEventProcessorWithJsonOutput { 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 d8911aae2b..fcdabce533 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -17,10 +17,13 @@ use codex_exec::exec_events::ConversationItem; use codex_exec::exec_events::ConversationItemDetails; use codex_exec::exec_events::ItemCompletedEvent; use codex_exec::exec_events::ItemStartedEvent; +use codex_exec::exec_events::ItemUpdatedEvent; use codex_exec::exec_events::PatchApplyStatus; use codex_exec::exec_events::PatchChangeKind; use codex_exec::exec_events::ReasoningItem; use codex_exec::exec_events::SessionCreatedEvent; +use codex_exec::exec_events::TodoItem as ExecTodoItem; +use codex_exec::exec_events::TodoListItem as ExecTodoListItem; use codex_exec::experimental_event_processor_with_json_output::ExperimentalEventProcessorWithJsonOutput; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -62,6 +65,171 @@ fn session_configured_produces_session_created_event() { ); } +#[test] +fn plan_update_emits_todo_list_started_updated_and_completed() { + use codex_core::plan_tool::PlanItemArg; + use codex_core::plan_tool::StepStatus; + use codex_core::plan_tool::UpdatePlanArgs; + + let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + + // First plan update => item.started (todo_list) + let first = event( + "p1", + EventMsg::PlanUpdate(UpdatePlanArgs { + explanation: None, + plan: vec![ + PlanItemArg { + step: "step one".to_string(), + status: StepStatus::Pending, + }, + PlanItemArg { + step: "step two".to_string(), + status: StepStatus::InProgress, + }, + ], + }), + ); + let out_first = ep.collect_conversation_events(&first); + assert_eq!( + out_first, + vec![ConversationEvent::ItemStarted(ItemStartedEvent { + item: ConversationItem { + id: "item_0".to_string(), + details: ConversationItemDetails::TodoList(ExecTodoListItem { + items: vec![ + ExecTodoItem { + text: "step one".to_string(), + completed: false + }, + ExecTodoItem { + text: "step two".to_string(), + completed: false + }, + ], + }), + }, + })] + ); + + // Second plan update in same turn => item.updated (same id) + let second = event( + "p2", + EventMsg::PlanUpdate(UpdatePlanArgs { + explanation: None, + plan: vec![ + PlanItemArg { + step: "step one".to_string(), + status: StepStatus::Completed, + }, + PlanItemArg { + step: "step two".to_string(), + status: StepStatus::InProgress, + }, + ], + }), + ); + let out_second = ep.collect_conversation_events(&second); + assert_eq!( + out_second, + vec![ConversationEvent::ItemUpdated(ItemUpdatedEvent { + item: ConversationItem { + id: "item_0".to_string(), + details: ConversationItemDetails::TodoList(ExecTodoListItem { + items: vec![ + ExecTodoItem { + text: "step one".to_string(), + completed: true + }, + ExecTodoItem { + text: "step two".to_string(), + completed: false + }, + ], + }), + }, + })] + ); + + // Task completes => item.completed (same id, latest state) + let complete = event( + "p3", + EventMsg::TaskComplete(codex_core::protocol::TaskCompleteEvent { + last_agent_message: None, + }), + ); + let out_complete = ep.collect_conversation_events(&complete); + assert_eq!( + out_complete, + vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { + item: ConversationItem { + id: "item_0".to_string(), + details: ConversationItemDetails::TodoList(ExecTodoListItem { + items: vec![ + ExecTodoItem { + text: "step one".to_string(), + completed: true + }, + ExecTodoItem { + text: "step two".to_string(), + completed: false + }, + ], + }), + }, + })] + ); +} + +#[test] +fn plan_update_after_complete_starts_new_todo_list_with_new_id() { + use codex_core::plan_tool::PlanItemArg; + use codex_core::plan_tool::StepStatus; + use codex_core::plan_tool::UpdatePlanArgs; + + let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + + // First turn: start + complete + let start = event( + "t1", + EventMsg::PlanUpdate(UpdatePlanArgs { + explanation: None, + plan: vec![PlanItemArg { + step: "only".to_string(), + status: StepStatus::Pending, + }], + }), + ); + let _ = ep.collect_conversation_events(&start); + let complete = event( + "t2", + EventMsg::TaskComplete(codex_core::protocol::TaskCompleteEvent { + last_agent_message: None, + }), + ); + let _ = ep.collect_conversation_events(&complete); + + // Second turn: a new todo list should have a new id + let start_again = event( + "t3", + EventMsg::PlanUpdate(UpdatePlanArgs { + explanation: None, + plan: vec![PlanItemArg { + step: "again".to_string(), + status: StepStatus::Pending, + }], + }), + ); + let out = ep.collect_conversation_events(&start_again); + + match &out[0] { + ConversationEvent::ItemStarted(ItemStartedEvent { item }) => { + assert_eq!(&item.id, "item_1"); + } + other => panic!("unexpected event: {other:?}"), + } +} + #[test] fn agent_reasoning_produces_item_completed_reasoning() { let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); From e555a36c6aa6e4dcc0f060418938a2f9349816c3 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Fri, 26 Sep 2025 10:13:37 -0700 Subject: [PATCH 08/31] [MCP] Introduce an experimental official rust sdk based mcp client (#4252) The [official Rust SDK](https://github.com/modelcontextprotocol/rust-sdk/tree/57fc428c578a1a3fe851ee0838bf068bda120eb3) has come a long way since we first started our mcp client implementation 5 months ago and, today, it is much more complete than our own stdio-only implementation. This PR introduces a new config flag `experimental_use_rmcp_client` which will use a new mcp client powered by the sdk instead of our own. To keep this PR simple, I've only implemented the same stdio MCP functionality that we had but will expand on it with future PRs. --------- Co-authored-by: pakrym-oai --- codex-rs/Cargo.lock | 206 +++++++++++++++++- codex-rs/Cargo.toml | 3 + codex-rs/core/Cargo.toml | 2 + codex-rs/core/src/codex.rs | 5 +- codex-rs/core/src/config.rs | 10 + codex-rs/core/src/mcp_connection_manager.rs | 138 ++++++++---- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 162 ++++++++++++++ codex-rs/mcp-client/src/main.rs | 5 +- codex-rs/mcp-client/src/mcp_client.rs | 3 +- codex-rs/rmcp-client/Cargo.toml | 34 +++ .../rmcp-client/src/bin/rmcp_test_server.rs | 142 ++++++++++++ codex-rs/rmcp-client/src/lib.rs | 5 + .../rmcp-client/src/logging_client_handler.rs | 134 ++++++++++++ codex-rs/rmcp-client/src/rmcp_client.rs | 183 ++++++++++++++++ codex-rs/rmcp-client/src/utils.rs | 160 ++++++++++++++ codex-rs/tui/src/lib.rs | 5 +- 17 files changed, 1136 insertions(+), 62 deletions(-) create mode 100644 codex-rs/core/tests/suite/rmcp_client.rs create mode 100644 codex-rs/rmcp-client/Cargo.toml create mode 100644 codex-rs/rmcp-client/src/bin/rmcp_test_server.rs create mode 100644 codex-rs/rmcp-client/src/lib.rs create mode 100644 codex-rs/rmcp-client/src/logging_client_handler.rs create mode 100644 codex-rs/rmcp-client/src/rmcp_client.rs create mode 100644 codex-rs/rmcp-client/src/utils.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a50a82b438..ac792eaf3b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -688,9 +688,11 @@ dependencies = [ "codex-file-search", "codex-mcp-client", "codex-protocol", + "codex-rmcp-client", "core_test_support", "dirs", "env-flags", + "escargot", "eventsource-stream", "futures", "indexmap 2.10.0", @@ -949,6 +951,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "codex-rmcp-client" +version = "0.0.0" +dependencies = [ + "anyhow", + "mcp-types", + "pretty_assertions", + "rmcp", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "codex-tui" version = "0.0.0" @@ -1266,8 +1282,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1284,13 +1310,38 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.104", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.104", ] @@ -1667,6 +1718,17 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "escargot" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11c3aea32bc97b500c9ca6a72b768a26e558264303d101d3409cf6d57a9ed0cf" +dependencies = [ + "log", + "serde", + "serde_json", +] + [[package]] name = "event-listener" version = "5.4.0" @@ -2496,7 +2558,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" dependencies = [ - "darling", + "darling 0.20.11", "indoc", "proc-macro2", "quote", @@ -2976,6 +3038,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -3398,7 +3472,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.28.0", "serial2", "shared_library", "shell-words", @@ -3486,6 +3560,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "8.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" +dependencies = [ + "futures", + "indexmap 2.10.0", + "nix 0.30.1", + "tokio", + "tracing", + "windows", +] + [[package]] name = "pulldown-cmark" version = "0.10.3" @@ -3812,6 +3900,42 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534fd1cd0601e798ac30545ff2b7f4a62c6f14edd4aaed1cc5eb1e85f69f09af" +dependencies = [ + "base64", + "chrono", + "futures", + "paste", + "pin-project-lite", + "process-wrap", + "rmcp-macros", + "schemars 1.0.4", + "serde", + "serde_json", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba777eb0e5f53a757e36f0e287441da0ab766564ba7201600eeb92a4753022e" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.104", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -3905,7 +4029,7 @@ dependencies = [ "libc", "log", "memchr", - "nix", + "nix 0.28.0", "radix_trie", "unicode-segmentation", "unicode-width 0.1.14", @@ -3986,7 +4110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", ] @@ -4009,8 +4133,10 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.0.4", "serde", "serde_json", ] @@ -4027,6 +4153,18 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.104", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4178,7 +4316,7 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.104", @@ -5502,6 +5640,28 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5515,6 +5675,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.0" @@ -5549,6 +5720,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -5676,6 +5857,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 33b1b303f0..af111c6f19 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -18,6 +18,7 @@ members = [ "ollama", "protocol", "protocol-ts", + "rmcp-client", "responses-api-proxy", "tui", "utils/readiness", @@ -49,6 +50,7 @@ codex-mcp-client = { path = "mcp-client" } codex-mcp-server = { path = "mcp-server" } codex-ollama = { path = "ollama" } codex-protocol = { path = "protocol" } +codex-rmcp-client = { path = "rmcp-client" } codex-protocol-ts = { path = "protocol-ts" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-tui = { path = "tui" } @@ -82,6 +84,7 @@ dotenvy = "0.15.7" env-flags = "0.1.1" env_logger = "0.11.5" eventsource-stream = "0.2.3" +escargot = "0.5" futures = "0.3" icu_decimal = "2.0.0" icu_locale_core = "2.0.0" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a1e7876a26..9b1c4888a4 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -22,6 +22,7 @@ chrono = { workspace = true, features = ["serde"] } codex-apply-patch = { workspace = true } codex-file-search = { workspace = true } codex-mcp-client = { workspace = true } +codex-rmcp-client = { workspace = true } codex-protocol = { workspace = true } dirs = { workspace = true } env-flags = { workspace = true } @@ -82,6 +83,7 @@ openssl-sys = { workspace = true, features = ["vendored"] } [dev-dependencies] assert_cmd = { workspace = true } core_test_support = { workspace = true } +escargot = { workspace = true } maplit = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 93850a6583..8b792887c0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -377,7 +377,10 @@ impl Session { // - load history metadata let rollout_fut = RolloutRecorder::new(&config, rollout_params); - let mcp_fut = McpConnectionManager::new(config.mcp_servers.clone()); + let mcp_fut = McpConnectionManager::new( + config.mcp_servers.clone(), + config.use_experimental_use_rmcp_client, + ); let default_shell_fut = shell::default_user_shell(); let history_meta_fut = crate::message_history::history_metadata(&config); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 508a3dc36f..5b5b60f8df 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -184,6 +184,10 @@ pub struct Config { /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, + /// If set to `true`, use the experimental official Rust MCP client. + /// https://github.com/modelcontextprotocol/rust-sdk + pub use_experimental_use_rmcp_client: bool, + /// Include the `view_image` tool that lets the agent attach a local image path to context. pub include_view_image_tool: bool, @@ -693,6 +697,7 @@ pub struct ConfigToml { pub experimental_use_exec_command_tool: Option, pub experimental_use_unified_exec_tool: Option, + pub experimental_use_rmcp_client: Option, pub projects: Option>, @@ -1043,6 +1048,7 @@ impl Config { use_experimental_unified_exec_tool: cfg .experimental_use_unified_exec_tool .unwrap_or(false), + use_experimental_use_rmcp_client: cfg.experimental_use_rmcp_client.unwrap_or(false), include_view_image_tool, active_profile: active_profile_name, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), @@ -1651,6 +1657,7 @@ model_verbosity = "high" tools_web_search_request: false, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, + use_experimental_use_rmcp_client: false, include_view_image_tool: true, active_profile: Some("o3".to_string()), disable_paste_burst: false, @@ -1709,6 +1716,7 @@ model_verbosity = "high" tools_web_search_request: false, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, + use_experimental_use_rmcp_client: false, include_view_image_tool: true, active_profile: Some("gpt3".to_string()), disable_paste_burst: false, @@ -1782,6 +1790,7 @@ model_verbosity = "high" tools_web_search_request: false, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, + use_experimental_use_rmcp_client: false, include_view_image_tool: true, active_profile: Some("zdr".to_string()), disable_paste_burst: false, @@ -1841,6 +1850,7 @@ model_verbosity = "high" tools_web_search_request: false, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, + use_experimental_use_rmcp_client: false, include_view_image_tool: true, active_profile: Some("gpt5".to_string()), disable_paste_burst: false, diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index e9c95fc80b..5648e20b3b 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -16,6 +16,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use codex_mcp_client::McpClient; +use codex_rmcp_client::RmcpClient; use mcp_types::ClientCapabilities; use mcp_types::Implementation; use mcp_types::Tool; @@ -86,11 +87,64 @@ struct ToolInfo { } struct ManagedClient { - client: Arc, + client: McpClientAdapter, startup_timeout: Duration, tool_timeout: Option, } +#[derive(Clone)] +enum McpClientAdapter { + Legacy(Arc), + Rmcp(Arc), +} + +impl McpClientAdapter { + async fn new_stdio_client( + use_rmcp_client: bool, + program: OsString, + args: Vec, + env: Option>, + params: mcp_types::InitializeRequestParams, + startup_timeout: Duration, + ) -> Result { + tracing::error!( + "new_stdio_client use_rmcp_client: {use_rmcp_client} program: {program:?} args: {args:?} env: {env:?} params: {params:?} startup_timeout: {startup_timeout:?}" + ); + if use_rmcp_client { + let client = Arc::new(RmcpClient::new_stdio_client(program, args, env).await?); + client.initialize(params, Some(startup_timeout)).await?; + Ok(McpClientAdapter::Rmcp(client)) + } else { + let client = Arc::new(McpClient::new_stdio_client(program, args, env).await?); + client.initialize(params, Some(startup_timeout)).await?; + Ok(McpClientAdapter::Legacy(client)) + } + } + + async fn list_tools( + &self, + params: Option, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(client) => client.list_tools(params, timeout).await, + McpClientAdapter::Rmcp(client) => client.list_tools(params, timeout).await, + } + } + + async fn call_tool( + &self, + name: String, + arguments: Option, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(client) => client.call_tool(name, arguments, timeout).await, + McpClientAdapter::Rmcp(client) => client.call_tool(name, arguments, timeout).await, + } + } +} + /// A thin wrapper around a set of running [`McpClient`] instances. #[derive(Default)] pub(crate) struct McpConnectionManager { @@ -115,12 +169,15 @@ impl McpConnectionManager { /// user should be informed about these errors. pub async fn new( mcp_servers: HashMap, + use_rmcp_client: bool, ) -> Result<(Self, ClientStartErrors)> { // Early exit if no servers are configured. if mcp_servers.is_empty() { return Ok((Self::default(), ClientStartErrors::default())); } + tracing::error!("new mcp_servers: {mcp_servers:?} use_rmcp_client: {use_rmcp_client}"); + // Launch all configured servers concurrently. let mut join_set = JoinSet::new(); let mut errors = ClientStartErrors::new(); @@ -137,57 +194,48 @@ impl McpConnectionManager { } let startup_timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT); - let tool_timeout = cfg.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT); + let use_rmcp_client_flag = use_rmcp_client; join_set.spawn(async move { let McpServerConfig { command, args, env, .. } = cfg; - let client_res = McpClient::new_stdio_client( - command.into(), - args.into_iter().map(OsString::from).collect(), + let command_os: OsString = command.into(); + let args_os: Vec = args.into_iter().map(Into::into).collect(); + let params = mcp_types::InitializeRequestParams { + capabilities: ClientCapabilities { + experimental: None, + roots: None, + sampling: None, + // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities + // indicates this should be an empty object. + elicitation: Some(json!({})), + }, + client_info: Implementation { + name: "codex-mcp-client".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + title: Some("Codex".into()), + // This field is used by Codex when it is an MCP + // server: it should not be used when Codex is + // an MCP client. + user_agent: None, + }, + protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_owned(), + }; + + let client = McpClientAdapter::new_stdio_client( + use_rmcp_client_flag, + command_os, + args_os, env, + params, + startup_timeout, ) - .await; - match client_res { - Ok(client) => { - // Initialize the client. - let params = mcp_types::InitializeRequestParams { - capabilities: ClientCapabilities { - experimental: None, - roots: None, - sampling: None, - // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities - // indicates this should be an empty object. - elicitation: Some(json!({})), - }, - client_info: Implementation { - name: "codex-mcp-client".to_owned(), - version: env!("CARGO_PKG_VERSION").to_owned(), - title: Some("Codex".into()), - // This field is used by Codex when it is an MCP - // server: it should not be used when Codex is - // an MCP client. - user_agent: None, - }, - protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_owned(), - }; - let initialize_notification_params = None; - let init_result = client - .initialize( - params, - initialize_notification_params, - Some(startup_timeout), - ) - .await; - ( - (server_name, tool_timeout), - init_result.map(|_| (client, startup_timeout)), - ) - } - Err(e) => ((server_name, tool_timeout), Err(e.into())), - } + .await + .map(|c| (c, startup_timeout)); + + ((server_name, tool_timeout), client) }); } @@ -207,7 +255,7 @@ impl McpConnectionManager { clients.insert( server_name, ManagedClient { - client: Arc::new(client), + client, startup_timeout, tool_timeout: Some(tool_timeout), }, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 0e4e725c36..5dd2cb673e 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -14,6 +14,7 @@ mod live_cli; mod model_overrides; mod prompt_caching; mod review; +mod rmcp_client; mod rollout_list_find; mod seatbelt; mod stream_error_allows_next_turn; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs new file mode 100644 index 0000000000..2ebe9f011c --- /dev/null +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -0,0 +1,162 @@ +use std::collections::HashMap; +use std::time::Duration; + +use codex_core::config_types::McpServerConfig; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InputItem; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use core_test_support::responses; +use core_test_support::responses::mount_sse_once; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; +use escargot::CargoBuild; +use serde_json::Value; +use wiremock::matchers::any; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rmcp_tool_call_round_trip() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + + let call_id = "call-123"; + let server_name = "rmcp"; + let tool_name = format!("{server_name}__echo"); + + mount_sse_once( + &server, + any(), + responses::sse(vec![ + serde_json::json!({ + "type": "response.created", + "response": {"id": "resp-1"} + }), + responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + mount_sse_once( + &server, + any(), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let expected_env_value = "propagated-env"; + let rmcp_test_server_bin = CargoBuild::new() + .package("codex-rmcp-client") + .bin("rmcp_test_server") + .run()? + .path() + .to_string_lossy() + .into_owned(); + + let fixture = test_codex() + .with_config(move |config| { + config.use_experimental_use_rmcp_client = true; + config.mcp_servers.insert( + server_name.to_string(), + McpServerConfig { + command: rmcp_test_server_bin.clone(), + args: Vec::new(), + env: Some(HashMap::from([( + "MCP_TEST_VALUE".to_string(), + expected_env_value.to_string(), + )])), + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + }, + ); + }) + .build(&server) + .await?; + let session_model = fixture.session_configured.model.clone(); + + fixture + .codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "call the rmcp echo tool".into(), + }], + final_output_json_schema: None, + cwd: fixture.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + eprintln!("waiting for mcp tool call begin event"); + let begin_event = wait_for_event_with_timeout( + &fixture.codex, + |ev| { + eprintln!("ev: {ev:?}"); + matches!(ev, EventMsg::McpToolCallBegin(_)) + }, + Duration::from_secs(10), + ) + .await; + + eprintln!("mcp tool call begin event: {begin_event:?}"); + let EventMsg::McpToolCallBegin(begin) = begin_event else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(begin.invocation.server, server_name); + assert_eq!(begin.invocation.tool, "echo"); + + let end_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallEnd(_)) + }) + .await; + eprintln!("end_event: {end_event:?}"); + let EventMsg::McpToolCallEnd(end) = end_event else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + + let result = end + .result + .as_ref() + .expect("rmcp echo tool should return success"); + assert_eq!(result.is_error, Some(false)); + assert!( + result.content.is_empty(), + "content should default to an empty array" + ); + + let structured = result + .structured_content + .as_ref() + .expect("structured content"); + let Value::Object(map) = structured else { + panic!("structured content should be an object: {structured:?}"); + }; + let echo_value = map + .get("echo") + .and_then(Value::as_str) + .expect("echo payload present"); + assert_eq!(echo_value, "ping"); + let env_value = map + .get("env") + .and_then(Value::as_str) + .expect("env snapshot inserted"); + assert_eq!(env_value, expected_env_value); + + let task_complete_event = + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + eprintln!("task_complete_event: {task_complete_event:?}"); + + server.verify().await; + + Ok(()) +} diff --git a/codex-rs/mcp-client/src/main.rs b/codex-rs/mcp-client/src/main.rs index d25bca4ba3..f46058b99e 100644 --- a/codex-rs/mcp-client/src/main.rs +++ b/codex-rs/mcp-client/src/main.rs @@ -70,11 +70,8 @@ async fn main() -> Result<()> { }, protocol_version: MCP_SCHEMA_VERSION.to_owned(), }; - let initialize_notification_params = None; let timeout = Some(Duration::from_secs(10)); - let response = client - .initialize(params, initialize_notification_params, timeout) - .await?; + let response = client.initialize(params, timeout).await?; eprintln!("initialize response: {response:?}"); // Issue `tools/list` request (no params). diff --git a/codex-rs/mcp-client/src/mcp_client.rs b/codex-rs/mcp-client/src/mcp_client.rs index 505df6bd4e..087335e66b 100644 --- a/codex-rs/mcp-client/src/mcp_client.rs +++ b/codex-rs/mcp-client/src/mcp_client.rs @@ -315,13 +315,12 @@ impl McpClient { pub async fn initialize( &self, initialize_params: InitializeRequestParams, - initialize_notification_params: Option, timeout: Option, ) -> Result { let response = self .send_request::(initialize_params, timeout) .await?; - self.send_notification::(initialize_notification_params) + self.send_notification::(None) .await?; Ok(response) } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml new file mode 100644 index 0000000000..da9989e531 --- /dev/null +++ b/codex-rs/rmcp-client/Cargo.toml @@ -0,0 +1,34 @@ +[package] +edition = "2024" +name = "codex-rmcp-client" +version = { workspace = true } + +[lints] +workspace = true + +[dependencies] +anyhow = "1" +mcp-types = { path = "../mcp-types" } +rmcp = { version = "0.7.0", default-features = false, features = [ + "base64", + "client", + "macros", + "schemars", + "server", + "transport-child-process", +] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = [ + "io-util", + "macros", + "process", + "rt-multi-thread", + "sync", + "io-std", + "time", +] } +tracing = { version = "0.1.41", features = ["log"] } + +[dev-dependencies] +pretty_assertions = "1.4.1" diff --git a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs new file mode 100644 index 0000000000..23b2f93b38 --- /dev/null +++ b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs @@ -0,0 +1,142 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::Arc; + +use rmcp::ErrorData as McpError; +use rmcp::ServiceExt; +use rmcp::handler::server::ServerHandler; +use rmcp::model::CallToolRequestParam; +use rmcp::model::CallToolResult; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::PaginatedRequestParam; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use serde::Deserialize; +use serde_json::json; +use tokio::task; + +#[derive(Clone)] +struct TestToolServer { + tools: Arc>, +} +pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { + (tokio::io::stdin(), tokio::io::stdout()) +} +impl TestToolServer { + fn new() -> Self { + let tools = vec![Self::echo_tool()]; + Self { + tools: Arc::new(tools), + } + } + + fn echo_tool() -> Tool { + #[expect(clippy::expect_used)] + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "message": { "type": "string" }, + "env_var": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": false + })) + .expect("echo tool schema should deserialize"); + + Tool::new( + Cow::Borrowed("echo"), + Cow::Borrowed("Echo back the provided message and include environment data."), + Arc::new(schema), + ) + } +} + +#[derive(Deserialize)] +struct EchoArgs { + message: String, + #[allow(dead_code)] + env_var: Option, +} + +impl ServerHandler for TestToolServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ..ServerInfo::default() + } + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let tools = self.tools.clone(); + async move { + Ok(ListToolsResult { + tools: (*tools).clone(), + next_cursor: None, + }) + } + } + + async fn call_tool( + &self, + request: CallToolRequestParam, + _context: rmcp::service::RequestContext, + ) -> Result { + match request.name.as_ref() { + "echo" => { + let args: EchoArgs = match request.arguments { + Some(arguments) => serde_json::from_value(serde_json::Value::Object( + arguments.into_iter().collect(), + )) + .map_err(|err| McpError::invalid_params(err.to_string(), None))?, + None => { + return Err(McpError::invalid_params( + "missing arguments for echo tool", + None, + )); + } + }; + + let env_snapshot: HashMap = std::env::vars().collect(); + let structured_content = json!({ + "echo": args.message, + "env": env_snapshot.get("MCP_TEST_VALUE"), + }); + + Ok(CallToolResult { + content: Vec::new(), + structured_content: Some(structured_content), + is_error: Some(false), + meta: None, + }) + } + other => Err(McpError::invalid_params( + format!("unknown tool: {other}"), + None, + )), + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + eprintln!("starting rmcp test server"); + // Run the server with STDIO transport. If the client disconnects we simply + // bubble up the error so the process exits. + let service = TestToolServer::new(); + let running = service.serve(stdio()).await?; + + // Wait for the client to finish interacting with the server. + running.waiting().await?; + // Drain background tasks to ensure clean shutdown. + task::yield_now().await; + Ok(()) +} diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs new file mode 100644 index 0000000000..ef5088406c --- /dev/null +++ b/codex-rs/rmcp-client/src/lib.rs @@ -0,0 +1,5 @@ +mod logging_client_handler; +mod rmcp_client; +mod utils; + +pub use rmcp_client::RmcpClient; diff --git a/codex-rs/rmcp-client/src/logging_client_handler.rs b/codex-rs/rmcp-client/src/logging_client_handler.rs new file mode 100644 index 0000000000..85d237b0e9 --- /dev/null +++ b/codex-rs/rmcp-client/src/logging_client_handler.rs @@ -0,0 +1,134 @@ +use rmcp::ClientHandler; +use rmcp::RoleClient; +use rmcp::model::CancelledNotificationParam; +use rmcp::model::ClientInfo; +use rmcp::model::CreateElicitationRequestParam; +use rmcp::model::CreateElicitationResult; +use rmcp::model::ElicitationAction; +use rmcp::model::LoggingLevel; +use rmcp::model::LoggingMessageNotificationParam; +use rmcp::model::ProgressNotificationParam; +use rmcp::model::ResourceUpdatedNotificationParam; +use rmcp::service::NotificationContext; +use rmcp::service::RequestContext; +use tracing::debug; +use tracing::error; +use tracing::info; +use tracing::warn; + +#[derive(Debug, Clone)] +pub(crate) struct LoggingClientHandler { + client_info: ClientInfo, +} + +impl LoggingClientHandler { + pub(crate) fn new(client_info: ClientInfo) -> Self { + Self { client_info } + } +} + +impl ClientHandler for LoggingClientHandler { + // TODO (CODEX-3571): support elicitations. + async fn create_elicitation( + &self, + request: CreateElicitationRequestParam, + _context: RequestContext, + ) -> Result { + info!( + "MCP server requested elicitation ({}). Elicitations are not supported yet. Declining.", + request.message + ); + Ok(CreateElicitationResult { + action: ElicitationAction::Decline, + content: None, + }) + } + + async fn on_cancelled( + &self, + params: CancelledNotificationParam, + _context: NotificationContext, + ) { + info!( + "MCP server cancelled request (request_id: {}, reason: {:?})", + params.request_id, params.reason + ); + } + + async fn on_progress( + &self, + params: ProgressNotificationParam, + _context: NotificationContext, + ) { + info!( + "MCP server progress notification (token: {:?}, progress: {}, total: {:?}, message: {:?})", + params.progress_token, params.progress, params.total, params.message + ); + } + + async fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + _context: NotificationContext, + ) { + info!("MCP server resource updated (uri: {})", params.uri); + } + + async fn on_resource_list_changed(&self, _context: NotificationContext) { + info!("MCP server resource list changed"); + } + + async fn on_tool_list_changed(&self, _context: NotificationContext) { + info!("MCP server tool list changed"); + } + + async fn on_prompt_list_changed(&self, _context: NotificationContext) { + info!("MCP server prompt list changed"); + } + + fn get_info(&self) -> ClientInfo { + self.client_info.clone() + } + + async fn on_logging_message( + &self, + params: LoggingMessageNotificationParam, + _context: NotificationContext, + ) { + let LoggingMessageNotificationParam { + level, + logger, + data, + } = params; + let logger = logger.as_deref(); + match level { + LoggingLevel::Emergency + | LoggingLevel::Alert + | LoggingLevel::Critical + | LoggingLevel::Error => { + error!( + "MCP server log message (level: {:?}, logger: {:?}, data: {})", + level, logger, data + ); + } + LoggingLevel::Warning => { + warn!( + "MCP server log message (level: {:?}, logger: {:?}, data: {})", + level, logger, data + ); + } + LoggingLevel::Notice | LoggingLevel::Info => { + info!( + "MCP server log message (level: {:?}, logger: {:?}, data: {})", + level, logger, data + ); + } + LoggingLevel::Debug => { + debug!( + "MCP server log message (level: {:?}, logger: {:?}, data: {})", + level, logger, data + ); + } + } + } +} diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs new file mode 100644 index 0000000000..c7ac1ecc9a --- /dev/null +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; +use std::ffi::OsString; +use std::io; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use mcp_types::CallToolRequestParams; +use mcp_types::CallToolResult; +use mcp_types::InitializeRequestParams; +use mcp_types::InitializeResult; +use mcp_types::ListToolsRequestParams; +use mcp_types::ListToolsResult; +use rmcp::model::CallToolRequestParam; +use rmcp::model::InitializeRequestParam; +use rmcp::model::PaginatedRequestParam; +use rmcp::service::RoleClient; +use rmcp::service::RunningService; +use rmcp::service::{self}; +use rmcp::transport::child_process::TokioChildProcess; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use tokio::process::Command; +use tokio::sync::Mutex; +use tokio::time; +use tracing::info; +use tracing::warn; + +use crate::logging_client_handler::LoggingClientHandler; +use crate::utils::convert_call_tool_result; +use crate::utils::convert_to_mcp; +use crate::utils::convert_to_rmcp; +use crate::utils::create_env_for_mcp_server; +use crate::utils::run_with_timeout; + +enum ClientState { + Connecting { + transport: Option, + }, + Ready { + service: Arc>, + }, +} + +/// MCP client implemented on top of the official `rmcp` SDK. +/// https://github.com/modelcontextprotocol/rust-sdk +pub struct RmcpClient { + state: Mutex, +} + +impl RmcpClient { + pub async fn new_stdio_client( + program: OsString, + args: Vec, + env: Option>, + ) -> io::Result { + let program_name = program.to_string_lossy().into_owned(); + let mut command = Command::new(&program); + command + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .env_clear() + .envs(create_env_for_mcp_server(env)) + .args(&args); + + let (transport, stderr) = TokioChildProcess::builder(command) + .stderr(Stdio::piped()) + .spawn()?; + + if let Some(stderr) = stderr { + tokio::spawn(async move { + let mut reader = BufReader::new(stderr).lines(); + loop { + match reader.next_line().await { + Ok(Some(line)) => { + info!("MCP server stderr ({program_name}): {line}"); + } + Ok(None) => break, + Err(error) => { + warn!("Failed to read MCP server stderr ({program_name}): {error}"); + break; + } + } + } + }); + } + + Ok(Self { + state: Mutex::new(ClientState::Connecting { + transport: Some(transport), + }), + }) + } + + /// Perform the initialization handshake with the MCP server. + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization + pub async fn initialize( + &self, + params: InitializeRequestParams, + timeout: Option, + ) -> Result { + let transport = { + let mut guard = self.state.lock().await; + match &mut *guard { + ClientState::Connecting { transport } => transport + .take() + .ok_or_else(|| anyhow!("client already initializing"))?, + ClientState::Ready { .. } => { + return Err(anyhow!("client already initialized")); + } + } + }; + + let client_info = convert_to_rmcp::<_, InitializeRequestParam>(params.clone())?; + let client_handler = LoggingClientHandler::new(client_info); + let service_future = service::serve_client(client_handler, transport); + + let service = match timeout { + Some(duration) => time::timeout(duration, service_future) + .await + .map_err(|_| anyhow!("timed out handshaking with MCP server after {duration:?}"))? + .map_err(|err| anyhow!("handshaking with MCP server failed: {err}"))?, + None => service_future + .await + .map_err(|err| anyhow!("handshaking with MCP server failed: {err}"))?, + }; + + let initialize_result_rmcp = service + .peer() + .peer_info() + .ok_or_else(|| anyhow!("handshake succeeded but server info was missing"))?; + let initialize_result = convert_to_mcp(initialize_result_rmcp)?; + + { + let mut guard = self.state.lock().await; + *guard = ClientState::Ready { + service: Arc::new(service), + }; + } + + Ok(initialize_result) + } + + pub async fn list_tools( + &self, + params: Option, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params = params + .map(convert_to_rmcp::<_, PaginatedRequestParam>) + .transpose()?; + + let fut = service.list_tools(rmcp_params); + let result = run_with_timeout(fut, timeout, "tools/list").await?; + convert_to_mcp(result) + } + + pub async fn call_tool( + &self, + name: String, + arguments: Option, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let params = CallToolRequestParams { arguments, name }; + let rmcp_params: CallToolRequestParam = convert_to_rmcp(params)?; + let fut = service.call_tool(rmcp_params); + let rmcp_result = run_with_timeout(fut, timeout, "tools/call").await?; + convert_call_tool_result(rmcp_result) + } + + async fn service(&self) -> Result>> { + let guard = self.state.lock().await; + match &*guard { + ClientState::Ready { service } => Ok(Arc::clone(service)), + ClientState::Connecting { .. } => Err(anyhow!("MCP client not initialized")), + } + } +} diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs new file mode 100644 index 0000000000..6b7bd89424 --- /dev/null +++ b/codex-rs/rmcp-client/src/utils.rs @@ -0,0 +1,160 @@ +use std::collections::HashMap; +use std::env; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use mcp_types::CallToolResult; +use rmcp::model::CallToolResult as RmcpCallToolResult; +use rmcp::service::ServiceError; +use serde_json::Value; +use tokio::time; + +pub(crate) async fn run_with_timeout( + fut: F, + timeout: Option, + label: &str, +) -> Result +where + F: std::future::Future>, +{ + if let Some(duration) = timeout { + let result = time::timeout(duration, fut) + .await + .with_context(|| anyhow!("timed out awaiting {label} after {duration:?}"))?; + result.map_err(|err| anyhow!("{label} failed: {err}")) + } else { + fut.await.map_err(|err| anyhow!("{label} failed: {err}")) + } +} + +pub(crate) fn convert_call_tool_result(result: RmcpCallToolResult) -> Result { + let mut value = serde_json::to_value(result)?; + if let Some(obj) = value.as_object_mut() + && (obj.get("content").is_none() + || obj.get("content").is_some_and(serde_json::Value::is_null)) + { + obj.insert("content".to_string(), Value::Array(Vec::new())); + } + serde_json::from_value(value).context("failed to convert call tool result") +} + +/// Convert from mcp-types to Rust SDK types. +/// +/// The Rust SDK types are the same as our mcp-types crate because they are both +/// derived from the same MCP specification. +/// As a result, it should be safe to convert directly from one to the other. +pub(crate) fn convert_to_rmcp(value: T) -> Result +where + T: serde::Serialize, + U: serde::de::DeserializeOwned, +{ + let json = serde_json::to_value(value)?; + serde_json::from_value(json).map_err(|err| anyhow!(err)) +} + +/// Convert from Rust SDK types to mcp-types. +/// +/// The Rust SDK types are the same as our mcp-types crate because they are both +/// derived from the same MCP specification. +/// As a result, it should be safe to convert directly from one to the other. +pub(crate) fn convert_to_mcp(value: T) -> Result +where + T: serde::Serialize, + U: serde::de::DeserializeOwned, +{ + let json = serde_json::to_value(value)?; + serde_json::from_value(json).map_err(|err| anyhow!(err)) +} + +pub(crate) fn create_env_for_mcp_server( + extra_env: Option>, +) -> HashMap { + DEFAULT_ENV_VARS + .iter() + .filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value))) + .chain(extra_env.unwrap_or_default()) + .collect() +} + +#[cfg(unix)] +pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[ + "HOME", + "LOGNAME", + "PATH", + "SHELL", + "USER", + "__CF_USER_TEXT_ENCODING", + "LANG", + "LC_ALL", + "TERM", + "TMPDIR", + "TZ", +]; + +#[cfg(windows)] +pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[ + "PATH", + "PATHEXT", + "USERNAME", + "USERDOMAIN", + "USERPROFILE", + "TEMP", + "TMP", +]; + +#[cfg(test)] +mod tests { + use super::*; + use mcp_types::ContentBlock; + use pretty_assertions::assert_eq; + use rmcp::model::CallToolResult as RmcpCallToolResult; + use serde_json::json; + + #[tokio::test] + async fn create_env_honors_overrides() { + let value = "custom".to_string(); + let env = create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())]))); + assert_eq!(env.get("TZ"), Some(&value)); + } + + #[test] + fn convert_call_tool_result_defaults_missing_content() -> Result<()> { + let structured_content = json!({ "key": "value" }); + let rmcp_result = RmcpCallToolResult { + content: vec![], + structured_content: Some(structured_content.clone()), + is_error: Some(true), + meta: None, + }; + + let result = convert_call_tool_result(rmcp_result)?; + + assert!(result.content.is_empty()); + assert_eq!(result.structured_content, Some(structured_content)); + assert_eq!(result.is_error, Some(true)); + + Ok(()) + } + + #[test] + fn convert_call_tool_result_preserves_existing_content() -> Result<()> { + let rmcp_result = RmcpCallToolResult::success(vec![rmcp::model::Content::text("hello")]); + + let result = convert_call_tool_result(rmcp_result)?; + + assert_eq!(result.content.len(), 1); + match &result.content[0] { + ContentBlock::TextContent(text_content) => { + assert_eq!(text_content.text, "hello"); + assert_eq!(text_content.r#type, "text"); + } + other => panic!("expected text content got {other:?}"), + } + assert_eq!(result.structured_content, None); + assert_eq!(result.is_error, Some(false)); + + Ok(()) + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 998ecb7434..25eec9d86e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -223,8 +223,9 @@ pub async fn run_main( // use RUST_LOG env var, default to info for codex crates. let env_filter = || { - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("codex_core=info,codex_tui=info")) + EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info") + }) }; // Build layered subscriber: From d3f6f6629ba89fb65c1e02e46baacbe7bc69ee47 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Fri, 26 Sep 2025 10:52:02 -0700 Subject: [PATCH 09/31] chore: dead code removal; remove frame count and stateful render helpers (#4310) --- codex-rs/tui/src/custom_terminal.rs | 74 ----------------------------- 1 file changed, 74 deletions(-) diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 407ab3297f..9de17412bd 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -29,9 +29,6 @@ use ratatui::buffer::Buffer; use ratatui::layout::Position; use ratatui::layout::Rect; use ratatui::layout::Size; -use ratatui::widgets::StatefulWidget; -use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; #[derive(Debug, Hash)] @@ -47,12 +44,8 @@ pub struct Frame<'a> { /// The buffer that is used to draw the current frame pub(crate) buffer: &'a mut Buffer, - - /// The frame count indicating the sequence number of this frame. - pub(crate) count: usize, } -#[allow(dead_code)] impl Frame<'_> { /// The area of the current frame /// @@ -65,16 +58,6 @@ impl Frame<'_> { self.viewport_area } - /// Render a [`Widget`] to the current buffer using [`Widget::render`]. - /// - /// 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). - /// - /// [`Layout`]: crate::layout::Layout - pub fn render_widget(&mut self, widget: W, area: Rect) { - widget.render(area, self.buffer); - } - /// 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 @@ -84,38 +67,6 @@ impl Frame<'_> { widget.render_ref(area, self.buffer); } - /// Render a [`StatefulWidget`] to the current buffer using [`StatefulWidget::render`]. - /// - /// 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). - /// - /// The last argument should be an instance of the [`StatefulWidget::State`] associated to the - /// given [`StatefulWidget`]. - /// - /// [`Layout`]: crate::layout::Layout - pub fn render_stateful_widget(&mut self, widget: W, area: Rect, state: &mut W::State) - where - W: StatefulWidget, - { - widget.render(area, self.buffer, state); - } - - /// Render a [`StatefulWidgetRef`] to the current buffer using - /// [`StatefulWidgetRef::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). - /// - /// The last argument should be an instance of the [`StatefulWidgetRef::State`] associated to - /// the given [`StatefulWidgetRef`]. - #[allow(clippy::needless_pass_by_value)] - pub fn render_stateful_widget_ref(&mut self, widget: W, area: Rect, state: &mut W::State) - where - W: StatefulWidgetRef, - { - widget.render_ref(area, self.buffer, state); - } - /// 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. /// @@ -134,23 +85,6 @@ impl Frame<'_> { pub fn buffer_mut(&mut self) -> &mut Buffer { self.buffer } - - /// Returns the current frame count. - /// - /// This method provides access to the frame count, which is a sequence number indicating - /// how many frames have been rendered up to (but not including) this one. It can be used - /// for purposes such as animation, performance tracking, or debugging. - /// - /// Each time a frame has been rendered, this count is incremented, - /// providing a consistent way to reference the order and number of frames processed by the - /// terminal. When count reaches its maximum value (`usize::MAX`), it wraps around to zero. - /// - /// This count is particularly useful when dealing with dynamic content or animations where the - /// state of the display changes over time. By tracking the frame count, developers can - /// synchronize updates or changes to the content with the rendering process. - pub const fn count(&self) -> usize { - self.count - } } #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] @@ -174,8 +108,6 @@ where /// 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, - /// Number of frames rendered up until current time. - frame_count: usize, } impl Drop for Terminal @@ -212,18 +144,15 @@ where viewport_area: Rect::new(0, cursor_pos.y, 0, 0), last_known_screen_size: screen_size, last_known_cursor_pos: cursor_pos, - frame_count: 0, }) } /// Get a Frame object which provides a consistent view into the terminal state for rendering. pub fn get_frame(&mut self) -> Frame<'_> { - let count = self.frame_count; Frame { cursor_position: None, viewport_area: self.viewport_area, buffer: self.current_buffer_mut(), - count, } } @@ -381,9 +310,6 @@ where // Flush self.backend.flush()?; - // increment frame count before returning from draw - self.frame_count = self.frame_count.wrapping_add(1); - Ok(()) } From 1fba99ed8530c11c16e7aa48951fda79ff854b80 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 26 Sep 2025 11:16:54 -0700 Subject: [PATCH 10/31] /status followup (#4304) - Render `send a message to load usage data` in the beginning of the session - Render `data not available yet` if received no rate limits - nit case - Deleted stall snapshots that were moved to `codex-rs/tui/src/status/snapshots` --- codex-rs/core/src/client.rs | 4 --- ...s_snapshot_includes_reasoning_details.snap | 19 ---------- ...snapshot_truncates_in_narrow_terminal.snap | 19 ---------- codex-rs/tui/src/status/card.rs | 13 +++---- codex-rs/tui/src/status/helpers.rs | 2 +- codex-rs/tui/src/status/rate_limits.rs | 3 +- ...tatus_snapshot_includes_monthly_limit.snap | 24 ++++++------- ...s_snapshot_includes_reasoning_details.snap | 6 ++-- ...s_snapshot_shows_empty_limits_message.snap | 18 ++++++++++ ...snapshot_shows_missing_limits_message.snap | 4 +-- ...snapshot_truncates_in_narrow_terminal.snap | 4 +-- codex-rs/tui/src/status/tests.rs | 36 +++++++++++++++++++ 12 files changed, 82 insertions(+), 70 deletions(-) delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index aedea4887b..e8aca68fe8 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -559,10 +559,6 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { "x-codex-secondary-reset-after-seconds", ); - if primary.is_none() && secondary.is_none() { - return None; - } - Some(RateLimitSnapshot { primary, secondary }) } diff --git a/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap deleted file mode 100644 index 1615305f99..0000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tui/src/status.rs -expression: sanitized ---- -/status - -╭──────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Model : gpt-5-codex (reasoning high, summaries detailed) │ -│ Directory : /workspace/tests │ -│ Approval : on-request │ -│ Sandbox : workspace-write │ -│ Agents.md : │ -│ │ -│ Token Usage : 1.9K total (1K input + 900 output) │ -│ 5h limit : [███████████████░░░░░] 72% used · resets 03:14 │ -│ Weekly limit : [█████████░░░░░░░░░░░] 45% used · resets 03:24 │ -╰──────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap deleted file mode 100644 index 44cad15213..0000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tui/src/status.rs -expression: sanitized ---- -/status - -╭────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Model : gpt-5-codex (reasoning high │ -│ Directory : /workspace/tests │ -│ Approval : on-request │ -│ Sandbox : read-only │ -│ Agents.md : │ -│ │ -│ Token Usage : 1.9K total (1K input + 900 │ -│ 5h limit : [███████████████░░░░░] 72% │ -│ · resets 03:14 │ -╰────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 8f7188d53f..d983ba27c0 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -23,7 +23,6 @@ use super::helpers::compose_agents_summary; use super::helpers::compose_model_display; use super::helpers::format_directory_display; use super::helpers::format_tokens_compact; -use super::rate_limits::RESET_BULLET; use super::rate_limits::RateLimitSnapshotDisplay; use super::rate_limits::StatusRateLimitData; use super::rate_limits::compose_rate_limit_data; @@ -149,8 +148,7 @@ impl StatusHistoryCell { let base_line = Line::from(base_spans.clone()); if let Some(resets_at) = row.resets_at.as_ref() { - let resets_span = - Span::from(format!("{RESET_BULLET} resets {resets_at}")).dim(); + let resets_span = Span::from(format!("(resets {resets_at})")).dim(); let mut inline_spans = base_spans.clone(); inline_spans.push(Span::from(" ").dim()); inline_spans.push(resets_span.clone()); @@ -171,7 +169,10 @@ impl StatusHistoryCell { lines } StatusRateLimitData::Missing => { - vec![formatter.line("Limits", vec![Span::from("data not available yet").dim()])] + vec![formatter.line( + "Limits", + vec![Span::from("send a message to load usage data").dim()], + )] } } } @@ -233,7 +234,7 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } - push_label(&mut labels, &mut seen, "Token Usage"); + push_label(&mut labels, &mut seen, "Token usage"); self.collect_rate_limit_labels(&mut seen, &mut labels); let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str)); @@ -263,7 +264,7 @@ impl HistoryCell for StatusHistoryCell { } lines.push(Line::from(Vec::>::new())); - lines.push(formatter.line("Token Usage", self.token_usage_spans())); + lines.push(formatter.line("Token usage", self.token_usage_spans())); lines.extend(self.rate_limit_lines(available_inner_width, &formatter)); diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 46185e1c0f..43dfd82098 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -162,7 +162,7 @@ pub(crate) fn format_reset_timestamp(dt: DateTime, captured_at: DateTime< if dt.date_naive() == captured_at.date_naive() { time } else { - format!("{} ({time})", dt.format("%-d %b")) + format!("{time} on {}", dt.format("%-d %b")) } } diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 8da3a1c61a..875001ef8a 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -11,7 +11,6 @@ use std::convert::TryFrom; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_FILLED: &str = "█"; const STATUS_LIMIT_BAR_EMPTY: &str = "░"; -pub(crate) const RESET_BULLET: &str = "·"; #[derive(Debug, Clone)] pub(crate) struct StatusRateLimitRow { @@ -105,7 +104,7 @@ pub(crate) fn compose_rate_limit_data( } if rows.is_empty() { - StatusRateLimitData::Missing + StatusRateLimitData::Available(vec![]) } else { StatusRateLimitData::Available(rows) } diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap index 25fd9c8a1d..67ed88cc06 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -4,15 +4,15 @@ expression: sanitized --- /status -╭──────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Model: gpt-5-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Approval: on-request │ -│ Sandbox: read-only │ -│ Agents.md: │ -│ │ -│ Token Usage: 1.2K total (800 input + 400 output) │ -│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used · resets 7 May (07:08) │ -╰──────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Model: gpt-5-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used (resets 07:08 on 7 May) │ +╰───────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index fc929e04c5..c445d1166e 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -13,7 +13,7 @@ expression: sanitized │ Sandbox: workspace-write │ │ Agents.md: │ │ │ -│ Token Usage: 1.9K total (1K input + 900 output) │ -│ 5h limit: [███████████████░░░░░] 72% used · resets 03:14 │ -│ Weekly limit: [█████████░░░░░░░░░░░] 45% used · resets 03:24 │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │ +│ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │ ╰───────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap new file mode 100644 index 0000000000..2766f0708c --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭──────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Model: gpt-5-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Limits: data not available yet │ +╰──────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index eabcc2a47a..21627522ec 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -13,6 +13,6 @@ expression: sanitized │ Sandbox: read-only │ │ Agents.md: │ │ │ -│ Token Usage: 750 total (500 input + 250 output) │ -│ Limits: data not available yet │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Limits: send a message to load usage data │ ╰──────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index 9f7e1a45f9..a943c852c7 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -13,7 +13,7 @@ expression: sanitized │ Sandbox: read-only │ │ Agents.md: │ │ │ -│ Token Usage: 1.9K total (1K input + 90 │ +│ Token usage: 1.9K total (1K input + 90 │ │ 5h limit: [███████████████░░░░░] 72% │ -│ · resets 03:14 │ +│ (resets 03:14) │ ╰────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index d583a5e552..b095819d19 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -248,3 +248,39 @@ fn status_snapshot_shows_missing_limits_message() { let sanitized = sanitize_directory(rendered_lines).join("\n"); assert_snapshot!(sanitized); } + +#[test] +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-codex".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let usage = TokenUsage { + input_tokens: 500, + cached_input_tokens: 0, + output_tokens: 250, + reasoning_output_tokens: 0, + total_tokens: 750, + }; + + let snapshot = RateLimitSnapshot { + primary: None, + secondary: None, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) + .single() + .expect("timestamp"); + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let composite = new_status_output(&config, &usage, &None, Some(&rate_display)); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} From 55801700de97454284b062f5f90382c261c06ada Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 26 Sep 2025 14:08:28 -0700 Subject: [PATCH 11/31] reject dangerous commands for AskForApproval::Never (#4307) If we detect a dangerous command but approval_policy is Never, simply reject the command. --- codex-rs/core/src/safety.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index a3462c3448..0c8dfd9a98 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -89,8 +89,15 @@ pub fn assess_command_safety( ) -> SafetyCheck { // Some commands look dangerous. Even if they are run inside a sandbox, // unless the user has explicitly approved them, we should ask, - // regardless of the approval policy and sandbox policy. + // or reject if the approval_policy tells us not to ask. if command_might_be_dangerous(command) && !approved.contains(command) { + if approval_policy == AskForApproval::Never { + return SafetyCheck::Reject { + reason: "dangerous command detected; rejected by user approval settings" + .to_string(), + }; + } + return SafetyCheck::AskUser; } @@ -376,7 +383,13 @@ mod tests { request_escalated_privileges, ); - assert_eq!(safety_check, SafetyCheck::AskUser); + assert_eq!( + safety_check, + SafetyCheck::Reject { + reason: "dangerous command detected; rejected by user approval settings" + .to_string(), + } + ); } #[test] From cc1b21e47f5e005c9b867ebea8c57f165228baa2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 26 Sep 2025 16:21:50 -0700 Subject: [PATCH 12/31] Add turn started/completed events and correct exit code on error (#4309) Adds new event for session completed that includes usage. Also ensures we return 1 on failures. ``` { "type": "session.created", "session_id": "019987a7-93e7-7b20-9e05-e90060e411ea" } { "type": "turn.started" } ... { "type": "turn.completed", "usage": { "input_tokens": 78913, "cached_input_tokens": 65280, "output_tokens": 1099 } } ``` --- README.md | 2 - codex-rs/exec/src/exec_events.rs | 20 ++++ ...mental_event_processor_with_json_output.rs | 42 ++++++- codex-rs/exec/src/lib.rs | 9 ++ .../tests/event_processor_with_json_output.rs | 107 +++++++++++++++--- codex-rs/exec/tests/suite/mod.rs | 1 + .../exec/tests/suite/server_error_exit.rs | 34 ++++++ 7 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 codex-rs/exec/tests/suite/server_error_exit.rs diff --git a/README.md b/README.md index ab93ecad22..ae04239ae6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -

OpenAI Codex CLI

npm i -g @openai/codex
or brew install codex

@@ -102,4 +101,3 @@ Codex CLI supports a rich set of configuration options, with preferences stored ## License This repository is licensed under the [Apache-2.0 License](LICENSE). - diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 38a431869b..07a9c01586 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -8,6 +8,10 @@ use ts_rs::TS; pub enum ConversationEvent { #[serde(rename = "session.created")] SessionCreated(SessionCreatedEvent), + #[serde(rename = "turn.started")] + TurnStarted(TurnStartedEvent), + #[serde(rename = "turn.completed")] + TurnCompleted(TurnCompletedEvent), #[serde(rename = "item.started")] ItemStarted(ItemStartedEvent), #[serde(rename = "item.updated")] @@ -23,6 +27,22 @@ pub struct SessionCreatedEvent { pub session_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)] +pub struct TurnStartedEvent {} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +pub struct TurnCompletedEvent { + pub usage: Usage, +} + +/// Minimal usage summary for a turn. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)] +pub struct Usage { + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub struct ItemStartedEvent { pub item: ConversationItem, diff --git a/codex-rs/exec/src/experimental_event_processor_with_json_output.rs b/codex-rs/exec/src/experimental_event_processor_with_json_output.rs index a2eddc178b..55af87a20b 100644 --- a/codex-rs/exec/src/experimental_event_processor_with_json_output.rs +++ b/codex-rs/exec/src/experimental_event_processor_with_json_output.rs @@ -23,6 +23,9 @@ use crate::exec_events::ReasoningItem; use crate::exec_events::SessionCreatedEvent; use crate::exec_events::TodoItem; use crate::exec_events::TodoListItem; +use crate::exec_events::TurnCompletedEvent; +use crate::exec_events::TurnStartedEvent; +use crate::exec_events::Usage; use codex_core::config::Config; use codex_core::plan_tool::StepStatus; use codex_core::plan_tool::UpdatePlanArgs; @@ -37,6 +40,7 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TaskStartedEvent; use tracing::error; use tracing::warn; @@ -48,6 +52,7 @@ pub struct ExperimentalEventProcessorWithJsonOutput { running_patch_applies: HashMap, // Tracks the todo list for the current turn (at most one per turn). running_todo_list: Option, + last_total_token_usage: Option, } #[derive(Debug, Clone)] @@ -70,6 +75,7 @@ impl ExperimentalEventProcessorWithJsonOutput { running_commands: HashMap::new(), running_patch_applies: HashMap::new(), running_todo_list: None, + last_total_token_usage: None, } } @@ -82,6 +88,14 @@ impl ExperimentalEventProcessorWithJsonOutput { EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), + EventMsg::TokenCount(ev) => { + if let Some(info) = &ev.info { + self.last_total_token_usage = Some(info.total_token_usage.clone()); + } + Vec::new() + } + EventMsg::TaskStarted(ev) => self.handle_task_started(ev), + EventMsg::TaskComplete(_) => self.handle_task_complete(), EventMsg::Error(ev) => vec![ConversationEvent::Error(ConversationErrorEvent { message: ev.message.clone(), })], @@ -89,7 +103,6 @@ impl ExperimentalEventProcessorWithJsonOutput { message: ev.message.clone(), })], EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev), - EventMsg::TaskComplete(_) => self.handle_task_complete(), _ => Vec::new(), } } @@ -283,7 +296,23 @@ impl ExperimentalEventProcessorWithJsonOutput { vec![ConversationEvent::ItemStarted(ItemStartedEvent { item })] } + fn handle_task_started(&self, _: &TaskStartedEvent) -> Vec { + vec![ConversationEvent::TurnStarted(TurnStartedEvent {})] + } + fn handle_task_complete(&mut self) -> Vec { + let usage = if let Some(u) = &self.last_total_token_usage { + Usage { + input_tokens: u.input_tokens, + cached_input_tokens: u.cached_input_tokens, + output_tokens: u.output_tokens, + } + } else { + Usage::default() + }; + + let mut items = Vec::new(); + if let Some(running) = self.running_todo_list.take() { let item = ConversationItem { id: running.item_id, @@ -291,11 +320,16 @@ impl ExperimentalEventProcessorWithJsonOutput { items: running.items, }), }; - return vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { + items.push(ConversationEvent::ItemCompleted(ItemCompletedEvent { item, - })]; + })); } - Vec::new() + + items.push(ConversationEvent::TurnCompleted(TurnCompletedEvent { + usage, + })); + + items } } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0ccb1e011b..da23fb1b23 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -331,7 +331,13 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any info!("Sent prompt with event ID: {initial_prompt_task_id}"); // Run the loop until the task is complete. + // Track whether a fatal error was reported by the server so we can + // exit with a non-zero status for automation-friendly signaling. + let mut error_seen = false; while let Some(event) = rx.recv().await { + if matches!(event.msg, EventMsg::Error(_)) { + error_seen = true; + } let shutdown: CodexStatus = event_processor.process_event(event); match shutdown { CodexStatus::Running => continue, @@ -343,6 +349,9 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any } } } + if error_seen { + std::process::exit(1); + } 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 fcdabce533..ebfb667bfb 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -24,6 +24,9 @@ use codex_exec::exec_events::ReasoningItem; use codex_exec::exec_events::SessionCreatedEvent; use codex_exec::exec_events::TodoItem as ExecTodoItem; use codex_exec::exec_events::TodoListItem as ExecTodoListItem; +use codex_exec::exec_events::TurnCompletedEvent; +use codex_exec::exec_events::TurnStartedEvent; +use codex_exec::exec_events::Usage; use codex_exec::experimental_event_processor_with_json_output::ExperimentalEventProcessorWithJsonOutput; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -65,6 +68,22 @@ fn session_configured_produces_session_created_event() { ); } +#[test] +fn task_started_produces_turn_started_event() { + let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let out = ep.collect_conversation_events(&event( + "t1", + EventMsg::TaskStarted(codex_core::protocol::TaskStartedEvent { + model_context_window: Some(32_000), + }), + )); + + assert_eq!( + out, + vec![ConversationEvent::TurnStarted(TurnStartedEvent {})] + ); +} + #[test] fn plan_update_emits_todo_list_started_updated_and_completed() { use codex_core::plan_tool::PlanItemArg; @@ -161,23 +180,28 @@ fn plan_update_emits_todo_list_started_updated_and_completed() { let out_complete = ep.collect_conversation_events(&complete); assert_eq!( out_complete, - vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { - item: ConversationItem { - id: "item_0".to_string(), - details: ConversationItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: true - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - })] + vec![ + ConversationEvent::ItemCompleted(ItemCompletedEvent { + item: ConversationItem { + id: "item_0".to_string(), + details: ConversationItemDetails::TodoList(ExecTodoListItem { + items: vec![ + ExecTodoItem { + text: "step one".to_string(), + completed: true + }, + ExecTodoItem { + text: "step two".to_string(), + completed: false + }, + ], + }), + }, + }), + ConversationEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + }), + ] ); } @@ -585,3 +609,52 @@ fn patch_apply_failure_produces_item_completed_patchapply_failed() { other => panic!("unexpected event: {other:?}"), } } + +#[test] +fn task_complete_produces_turn_completed_with_usage() { + let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + + // First, feed a TokenCount event with known totals. + let usage = codex_core::protocol::TokenUsage { + input_tokens: 1200, + cached_input_tokens: 200, + output_tokens: 345, + reasoning_output_tokens: 0, + total_tokens: 0, + }; + let info = codex_core::protocol::TokenUsageInfo { + total_token_usage: usage.clone(), + last_token_usage: usage, + model_context_window: None, + }; + let token_count_event = event( + "e1", + EventMsg::TokenCount(codex_core::protocol::TokenCountEvent { + info: Some(info), + rate_limits: None, + }), + ); + assert!( + ep.collect_conversation_events(&token_count_event) + .is_empty() + ); + + // Then TaskComplete should produce turn.completed with the captured usage. + let complete_event = event( + "e2", + EventMsg::TaskComplete(codex_core::protocol::TaskCompleteEvent { + last_agent_message: Some("done".to_string()), + }), + ); + let out = ep.collect_conversation_events(&complete_event); + assert_eq!( + out, + vec![ConversationEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage { + input_tokens: 1200, + cached_input_tokens: 200, + output_tokens: 345, + }, + })] + ); +} diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 79a3ae38b0..52f5bca34c 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -3,3 +3,4 @@ mod apply_patch; mod output_schema; mod resume; mod sandbox; +mod server_error_exit; diff --git a/codex-rs/exec/tests/suite/server_error_exit.rs b/codex-rs/exec/tests/suite/server_error_exit.rs new file mode 100644 index 0000000000..6b6358f0f4 --- /dev/null +++ b/codex-rs/exec/tests/suite/server_error_exit.rs @@ -0,0 +1,34 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; +use wiremock::matchers::any; + +/// Verify that when the server reports an error, `codex-exec` exits with a +/// non-zero status code so automation can detect failures. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exits_non_zero_when_server_reports_error() -> anyhow::Result<()> { + let test = test_codex_exec(); + + // Mock a simple Responses API SSE stream that immediately reports a + // `response.failed` event with an error message. + let server = responses::start_mock_server().await; + let body = responses::sse(vec![serde_json::json!({ + "type": "response.failed", + "response": { + "id": "resp_err_1", + "error": {"code": "rate_limit_exceeded", "message": "synthetic server error"} + } + })]); + responses::mount_sse_once(&server, any(), body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .arg("--experimental-json") + .assert() + .code(1); + + Ok(()) +} From 43b63ccae89cf911c1d3fecc4577a98b898d6048 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:35:56 -0700 Subject: [PATCH 13/31] update composer + user message styling (#4240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - the composer and user messages now have a colored background that stretches the entire width of the terminal. - the prompt character was changed from a cyan `▌` to a bold `›`. - the "working" shimmer now follows the "dark gray" color of the terminal, better matching the terminal's color scheme | Terminal + Background | Screenshot | |------------------------------|------------| | iTerm with dark bg | Screenshot
2025-09-25 at 11 44 52 AM | | iTerm with light bg | Screenshot
2025-09-25 at 11 46 29 AM | | iTerm with color bg | Screenshot
2025-09-25 at 11 47 12 AM | | Terminal.app with dark bg | Screenshot 2025-09-25 at 11 45 22 AM | | Terminal.app with light bg | Screenshot 2025-09-25 at 11 46 04 AM | | Terminal.app with color bg | Screenshot 2025-09-25 at 11 45 50 AM | --- codex-rs/core/tests/suite/client.rs | 6 + codex-rs/tui/Cargo.toml | 17 +- codex-rs/tui/src/app_backtrack.rs | 4 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 94 ++--- 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 | 2 +- codex-rs/tui/src/bottom_pane/mod.rs | 6 +- .../src/bottom_pane/selection_popup_common.rs | 23 +- ...mposer__tests__backspace_after_pastes.snap | 18 +- ...tom_pane__chat_composer__tests__empty.snap | 18 +- ...tom_pane__chat_composer__tests__large.snap | 18 +- ...chat_composer__tests__multiple_pastes.snap | 18 +- ..._chat_composer__tests__slash_popup_mo.snap | 8 +- ...tom_pane__chat_composer__tests__small.snap | 18 +- codex-rs/tui/src/chatwidget.rs | 9 +- ...et__tests__binary_size_ideal_response.snap | 13 +- ...chatwidget__tests__chat_small_idle_h1.snap | 2 +- ...chatwidget__tests__chat_small_idle_h2.snap | 2 +- ...chatwidget__tests__chat_small_idle_h3.snap | 2 +- ...twidget__tests__chat_small_running_h1.snap | 2 +- ...twidget__tests__chat_small_running_h2.snap | 2 +- ...twidget__tests__chat_small_running_h3.snap | 2 +- ...exec_and_status_layout_vt100_snapshot.snap | 9 +- ...t_markdown_code_blocks_vt100_snapshot.snap | 2 +- ...e_final_message_are_rendered_snapshot.snap | 2 +- ...n_message_without_deltas_are_rendered.snap | 2 +- ...atwidget__tests__status_widget_active.snap | 5 +- codex-rs/tui/src/chatwidget/tests.rs | 142 +------ codex-rs/tui/src/color.rs | 75 ++++ codex-rs/tui/src/custom_terminal.rs | 217 +++++++++- codex-rs/tui/src/exec_cell/render.rs | 2 +- codex-rs/tui/src/history_cell.rs | 48 ++- codex-rs/tui/src/insert_history.rs | 112 ++--- codex-rs/tui/src/key_hint.rs | 4 +- codex-rs/tui/src/lib.rs | 6 + codex-rs/tui/src/render/line_utils.rs | 2 +- codex-rs/tui/src/shimmer.rs | 28 +- ...wraps_and_prefixes_each_line_snapshot.snap | 8 +- codex-rs/tui/src/style.rs | 44 ++ codex-rs/tui/src/terminal_palette.rs | 398 ++++++++++++++++++ codex-rs/tui/src/test_backend.rs | 123 ++++++ codex-rs/tui/src/tui.rs | 8 + codex-rs/tui/src/wrapping.rs | 4 +- codex-rs/tui/tests/all.rs | 3 + codex-rs/tui/tests/suite/mod.rs | 1 - codex-rs/tui/tests/suite/vt100_history.rs | 130 +----- codex-rs/tui/tests/suite/vt100_live_commit.rs | 61 +-- .../tui/tests/suite/vt100_streaming_no_dup.rs | 76 ---- codex-rs/tui/tests/test_backend.rs | 4 + 50 files changed, 1181 insertions(+), 623 deletions(-) create mode 100644 codex-rs/tui/src/color.rs create mode 100644 codex-rs/tui/src/style.rs create mode 100644 codex-rs/tui/src/terminal_palette.rs create mode 100644 codex-rs/tui/src/test_backend.rs delete mode 100644 codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs create mode 100644 codex-rs/tui/tests/test_backend.rs diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a9b0bb1b76..cf15e02d29 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -361,6 +361,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn includes_base_instructions_override_in_request() { + skip_if_no_network!(); // Mock server let server = MockServer::start().await; @@ -558,6 +559,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn includes_user_instructions_message_in_request() { + skip_if_no_network!(); let server = MockServer::start().await; let first = ResponseTemplate::new(200) @@ -755,6 +757,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 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)]); @@ -899,6 +902,7 @@ async fn token_count_includes_rate_limits_snapshot() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); let server = MockServer::start().await; let response = ResponseTemplate::new(429) @@ -978,6 +982,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn azure_overrides_assign_properties_used_for_responses_url() { + skip_if_no_network!(); let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" }; // Mock server @@ -1054,6 +1059,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn env_var_overrides_loaded_auth() { + skip_if_no_network!(); let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" }; // Mock server diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index ffc49fd3b6..51f5c23578 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -40,23 +40,20 @@ codex-login = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } color-eyre = { workspace = true } -crossterm = { workspace = true, features = [ - "bracketed-paste", - "event-stream", -] } -dirs = { workspace = true } +crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diffy = { workspace = true } -image = { workspace = true, features = [ - "jpeg", - "png", -] } +dirs = { workspace = true } +image = { workspace = true, features = ["jpeg", "png"] } itertools = { workspace = true } lazy_static = { workspace = true } mcp-types = { workspace = true } path-clean = { 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", ] } @@ -80,11 +77,9 @@ tokio-stream = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } -pulldown-cmark = { workspace = true } unicode-segmentation = { workspace = true } unicode-width = { workspace = true } url = { workspace = true } -pathdiff = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index cbea2c02f7..cf03784d46 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -447,7 +447,7 @@ mod tests { .iter() .map(|span| span.content.as_ref()) .collect(); - assert_eq!(intro_text, "> intro"); + assert_eq!(intro_text, "• intro"); } #[test] @@ -479,7 +479,7 @@ mod tests { .iter() .map(|span| span.content.as_ref()) .collect(); - assert_eq!(intro_text, "> intro"); + assert_eq!(intro_text, "• intro"); let user_first = cells[1] .as_any() diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 55762979ad..b3c1eea6d9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -8,15 +8,10 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; -use ratatui::widgets::BorderType; -use ratatui::widgets::Borders; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; @@ -30,6 +25,8 @@ use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use crate::bottom_pane::paste_burst::FlushResult; use crate::slash_command::SlashCommand; +use crate::style::user_message_style; +use crate::terminal_palette; use codex_protocol::custom_prompts::CustomPrompt; use crate::app_event::AppEvent; @@ -97,8 +94,6 @@ enum ActivePopup { } const FOOTER_HINT_HEIGHT: u16 = 1; -const FOOTER_SPACING_HEIGHT: u16 = 1; -const FOOTER_HEIGHT_WITH_HINT: u16 = FOOTER_HINT_HEIGHT + FOOTER_SPACING_HEIGHT; impl ChatComposer { pub fn new( @@ -137,30 +132,40 @@ impl ChatComposer { } pub fn desired_height(&self, width: u16) -> u16 { - // Leave 1 column for the left border and 1 column for left padding self.textarea .desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) + + 2 + match &self.active_popup { - ActivePopup::None => FOOTER_HEIGHT_WITH_HINT, + ActivePopup::None => FOOTER_HINT_HEIGHT, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), } } - pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + fn layout_areas(&self, area: Rect) -> [Rect; 3] { 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::None => Constraint::Max(FOOTER_HEIGHT_WITH_HINT), + ActivePopup::None => Constraint::Max(FOOTER_HINT_HEIGHT), }; - let [textarea_rect, _] = + let mut area = area; + // Leave an empty row at the top, unless there isn't room. + if area.height > 1 { + area.height -= 1; + area.y += 1; + } + let [composer_rect, popup_rect] = Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area); - let mut textarea_rect = textarea_rect; - // Leave 1 for border and 1 for padding + let mut textarea_rect = composer_rect; textarea_rect.width = textarea_rect.width.saturating_sub(LIVE_PREFIX_COLS); textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS); + [composer_rect, textarea_rect, popup_rect] + } + + pub 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) } @@ -1232,19 +1237,13 @@ impl ChatComposer { impl WidgetRef for ChatComposer { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let (popup_constraint, hint_spacing) = match &self.active_popup { - ActivePopup::Command(popup) => ( - Constraint::Max(popup.calculate_required_height(area.width)), - 0, - ), - ActivePopup::File(popup) => (Constraint::Max(popup.calculate_required_height()), 0), - ActivePopup::None => ( - Constraint::Length(FOOTER_HEIGHT_WITH_HINT), - FOOTER_SPACING_HEIGHT, - ), - }; - let [textarea_rect, popup_rect] = - Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area); + let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); + if !matches!(self.active_popup, ActivePopup::None) { + buf.set_style( + popup_rect, + user_message_style(terminal_palette::default_bg()), + ); + } match &self.active_popup { ActivePopup::Command(popup) => { popup.render_ref(popup_rect, buf); @@ -1253,16 +1252,9 @@ impl WidgetRef for ChatComposer { popup.render_ref(popup_rect, buf); } ActivePopup::None => { - let hint_rect = if hint_spacing > 0 { - let [_, hint_rect] = Layout::vertical([ - Constraint::Length(hint_spacing), - Constraint::Length(FOOTER_HINT_HEIGHT), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; + let mut hint_rect = popup_rect; + hint_rect.x += 2; + hint_rect.width = hint_rect.width.saturating_sub(2); render_footer( hint_rect, buf, @@ -1276,23 +1268,17 @@ impl WidgetRef for ChatComposer { ); } } - let border_style = if self.has_focus { - Style::default().fg(Color::Cyan) - } else { - Style::default().add_modifier(Modifier::DIM) - }; - Block::default() - .borders(Borders::LEFT) - .border_type(BorderType::QuadrantOutside) - .border_style(border_style) - .render_ref( - Rect::new(textarea_rect.x, textarea_rect.y, 1, textarea_rect.height), - buf, - ); - let mut textarea_rect = textarea_rect; - // Leave 1 for border and 1 for padding - textarea_rect.width = textarea_rect.width.saturating_sub(LIVE_PREFIX_COLS); - textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS); + let style = user_message_style(terminal_palette::default_bg()); + let mut block_rect = composer_rect; + block_rect.y = composer_rect.y.saturating_sub(1); + block_rect.height = composer_rect.height.saturating_add(1); + Block::default().style(style).render_ref(block_rect, buf); + buf.set_span( + composer_rect.x, + composer_rect.y, + &"›".bold(), + composer_rect.width, + ); let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index a0933f4ae6..32216f0776 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -209,8 +209,8 @@ impl WidgetRef for CommandPopup { &rows, &self.state, MAX_POPUP_ROWS, - false, "no matches", + false, ); } } 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 65dfe6933b..e017b50410 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -144,8 +144,8 @@ impl WidgetRef for &FileSearchPopup { &rows_all, &self.state, MAX_POPUP_ROWS, - false, empty_message, + false, ); } } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index c95f1ee049..b20d7476a7 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -454,8 +454,8 @@ impl BottomPaneView for ListSelectionView { &rows, &self.state, MAX_POPUP_ROWS, - true, "no matches", + true, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 062ccdbb59..42af749bb7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -150,7 +150,9 @@ impl BottomPane { let status_height = self .status .as_ref() - .map_or(0, |status| status.desired_height(area.width)); + .map_or(0, |status| status.desired_height(area.width)) + .min(area.height.saturating_sub(1)); + Layout::vertical([Constraint::Max(status_height), Constraint::Min(1)]).areas(area) } } @@ -616,7 +618,7 @@ mod tests { // Composer placeholder should be visible somewhere below. let mut found_composer = false; - for y in 1..area.height.saturating_sub(2) { + 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(' ')); 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 38eaab7486..f3b238016a 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -8,9 +8,6 @@ use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::Block; -use ratatui::widgets::BorderType; -use ratatui::widgets::Borders; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use unicode_width::UnicodeWidthChar; @@ -119,15 +116,21 @@ pub(crate) fn render_rows( rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, - _dim_non_selected: bool, empty_message: &str, + include_border: bool, ) { - // Always draw a dim left border to match other popups. - let block = Block::default() - .borders(Borders::LEFT) - .border_type(BorderType::QuadrantOutside) - .border_style(Style::default().add_modifier(Modifier::DIM)); - block.render(area, buf); + if include_border { + use ratatui::widgets::Block; + use ratatui::widgets::BorderType; + use ratatui::widgets::Borders; + + // Always draw a dim left border to match other popups. + let block = Block::default() + .borders(Borders::LEFT) + .border_type(BorderType::QuadrantOutside) + .border_style(Style::default().add_modifier(Modifier::DIM)); + block.render(area, buf); + } // Content renders to the right of the border with the same live prefix // padding used by the composer so the popup aligns with the input text. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index c9f5cb9720..3f57cab0f6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -2,13 +2,13 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- -"▌ [Pasted Content 1002 chars][Pasted Content 1004 chars] " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " +" " +" " +" " +" " +" " +" " +" " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index 823a41cf62..beccbaf7c7 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -2,13 +2,13 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- -"▌ Ask Codex to do anything " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index d89c4033de..263c7cc549 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -2,13 +2,13 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- -"▌ [Pasted Content 1005 chars] " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"› [Pasted Content 1005 chars] " +" " +" " +" " +" " +" " +" " +" " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index 636d5c22af..beb2d8e464 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -2,13 +2,13 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- -"▌ [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " +" " +" " +" " +" " +" " +" " +" " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap index 9c667c0d08..4253262041 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -2,7 +2,7 @@ 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 " +" " +"› /mo " +" /model choose what model and reasoning effort to use " +" /mention mention a file " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index 111e45b65b..41f0f03d8b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -2,13 +2,13 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- -"▌ short " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " -"▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"› short " +" " +" " +" " +" " +" " +" " +" " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a373338f58..0bf77f9994 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -59,6 +59,7 @@ 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; @@ -79,13 +80,11 @@ use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PatchEventType; -use crate::history_cell::RateLimitSnapshotDisplay; use crate::markdown::append_markdown; use crate::slash_command::SlashCommand; +use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; -// streaming internals are provided by crate::streaming and crate::markdown_stream -use crate::bottom_pane::ApprovalRequest; mod interrupts; use self::interrupts::InterruptManager; mod agent; @@ -417,7 +416,7 @@ impl ChatWidget { .and_then(|window| window.window_minutes), ); - let display = history_cell::rate_limit_snapshot_display(&snapshot, Local::now()); + let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now()); self.rate_limit_snapshot = Some(display); if !warnings.is_empty() { @@ -1542,7 +1541,7 @@ impl ChatWidget { default_usage = TokenUsage::default(); &default_usage }; - self.add_to_history(history_cell::new_status_output( + self.add_to_history(crate::status::new_status_output( &self.config, usage_ref, &self.conversation_id, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap index bca67dfebe..97709b613c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -1,8 +1,15 @@ --- source: tui/src/chatwidget/tests.rs -expression: visible_after +expression: "lines[start_idx..].join(\"\\n\")" --- -> I’m going to scan the workspace and Cargo manifests to see build profiles and +• 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! + +• 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 @@ -103,7 +110,7 @@ expression: visible_after "Main Causes" and "Build-Mode Notes." I can also include brief suggestions for reducing size, but I want to stay focused on answering the user's question. -> Here’s what’s driving size in this workspace’s binaries. +• Here’s what’s driving size in this workspace’s binaries. Main Causes diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap index 3a6e6532e5..51f7901d3a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"▌ Ask Codex to do anything " +"› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap index 48e6154eb2..fb07268dd3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -2,5 +2,5 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"▌ Ask Codex to do anything " " " +"› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap index c77477a083..60ef79386d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -3,5 +3,5 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -"▌ Ask Codex to do anything " +"› Ask Codex to do anything " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap index 3a6e6532e5..51f7901d3a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"▌ Ask Codex to do anything " +"› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap index 917f012603..5164cd455d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " Thinking (0s • Esc to interrupt) " -"▌ Ask Codex to do anything " +"› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index c77477a083..60ef79386d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -3,5 +3,5 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -"▌ Ask Codex to do anything " +"› Ask Codex to do anything " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 9a2859046a..248a57f8f3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -1,8 +1,8 @@ --- source: tui/src/chatwidget/tests.rs -expression: visual +expression: term.backend().vt100().screen().contents() --- -> I’m going to search the repo for where “Change Approved” is rendered to update +• I’m going to search the repo for where “Change Approved” is rendered to update that view. • Explored @@ -11,6 +11,7 @@ expression: visual Investigating rendering code (0s • Esc to interrupt) -▌ Summarize recent commits -⏎ send ⌃J newline ⌃T transcript ⌃C quit +› Summarize recent commits + + ⏎ send ⌃J newline ⌃T transcript ⌃C quit diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap index ca4e72a94b..1ed73b5fa5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -2,7 +2,7 @@ source: tui/src/chatwidget/tests.rs expression: visual --- -> -- Indented code block (4 spaces) +• -- Indented code block (4 spaces) SELECT * FROM "users" WHERE "email" LIKE '%@example.com'; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap index ae86ddc85f..6062087181 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -> Here is the result. +• Here is the result. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap index ae86ddc85f..6062087181 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -> Here is the result. +• Here is the result. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index e28659e2aa..9c94206a44 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -5,7 +5,8 @@ expression: terminal.backend() " " " Analyzing (0s • Esc to interrupt) " " " -"▌ Ask Codex to do anything " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"› Ask Codex to do anything " +" " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 53ad6097a5..0c9ebc73aa 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::test_backend::VT100Backend; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::config::Config; @@ -81,6 +82,7 @@ fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json payload } +/* #[test] fn final_answer_without_newline_is_flushed_immediately() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); @@ -138,6 +140,7 @@ fn final_answer_without_newline_is_flushed_immediately() { "expected final answer text to be flushed to history" ); } +*/ #[test] fn resumed_initial_messages_render_history() { @@ -1001,7 +1004,7 @@ async fn binary_size_transcript_snapshot() { let width: u16 = 80; let height: u16 = 2000; let viewport = Rect::new(0, height - 1, width, 1); - let backend = ratatui::backend::TestBackend::new(width, height); + let backend = VT100Backend::new(width, height); let mut terminal = crate::custom_terminal::Terminal::with_options(backend) .expect("failed to construct terminal"); terminal.set_viewport_area(viewport); @@ -1010,7 +1013,6 @@ async fn binary_size_transcript_snapshot() { let file = open_fixture("binary-size-log.jsonl"); let reader = BufReader::new(file); let mut transcript = String::new(); - let mut ansi: Vec = Vec::new(); let mut has_emitted_history = false; for line in reader.lines() { @@ -1071,11 +1073,7 @@ async fn binary_size_transcript_snapshot() { } has_emitted_history = true; transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + crate::insert_history::insert_history_lines(&mut terminal, lines); } } } @@ -1096,11 +1094,7 @@ async fn binary_size_transcript_snapshot() { } has_emitted_history = true; transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + crate::insert_history::insert_history_lines(&mut terminal, lines); } } } @@ -1111,13 +1105,12 @@ async fn binary_size_transcript_snapshot() { // Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line // and drop trailing empty lines so the shape matches the ideal fixture exactly. - let mut parser = vt100::Parser::new(height, width, 0); - parser.process(&ansi); + let screen = terminal.backend().vt100().screen(); let mut lines: Vec = Vec::with_capacity(height as usize); for row in 0..height { let mut s = String::with_capacity(width as usize); for col in 0..width { - if let Some(cell) = parser.screen().cell(row, col) { + if let Some(cell) = screen.cell(row, col) { if let Some(ch) = cell.contents().chars().next() { s.push(ch); } else { @@ -1144,7 +1137,7 @@ async fn binary_size_transcript_snapshot() { // Prefer the first assistant content line (blockquote '>' prefix) after the marker; // fallback to the first non-empty, non-'thinking' line. let start_idx = (last_marker_line_idx + 1..lines.len()) - .find(|&idx| lines[idx].trim_start().starts_with('>')) + .find(|&idx| lines[idx].trim_start().starts_with('•')) .unwrap_or_else(|| { (last_marker_line_idx + 1..lines.len()) .find(|&idx| { @@ -1154,28 +1147,8 @@ async fn binary_size_transcript_snapshot() { .expect("no content line found after marker") }); - let mut compare_lines: Vec = Vec::new(); - // Ensure the first line is trimmed-left to match the fixture shape. - compare_lines.push(lines[start_idx].trim_start().to_string()); - compare_lines.extend(lines[(start_idx + 1)..].iter().cloned()); - let visible_after = compare_lines.join("\n"); - - // Normalize: drop a leading 'thinking' line if present to avoid coupling - // to whether the reasoning header is rendered in history. - fn drop_leading_thinking(s: &str) -> String { - let mut it = s.lines(); - let first = it.next(); - let rest = it.collect::>().join("\n"); - if first.is_some_and(|l| l.trim() == "thinking") { - rest - } else { - s.to_string() - } - } - let visible_after = drop_leading_thinking(&visible_after); - // Snapshot the normalized visible transcript following the banner. - assert_snapshot!("binary_size_ideal_response", visible_after); + assert_snapshot!("binary_size_ideal_response", lines[start_idx..].join("\n")); } // @@ -2104,66 +2077,25 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { chat.bottom_pane .set_composer_text("Summarize recent commits".to_string()); - // Dimensions 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, width, ui_height); + let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); - // Use TestBackend for the terminal (no real ANSI emitted by drawing), - // but capture VT100 escape stream for history insertion with a separate writer. - let backend = ratatui::backend::TestBackend::new(width, vt_height); + let backend = VT100Backend::new(width, vt_height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); term.set_viewport_area(viewport); - // 1) Apply any pending history insertions by emitting ANSI to a buffer via insert_history_lines_to_writer - let mut ansi: Vec = Vec::new(); for lines in drain_insert_history(&mut rx) { - crate::insert_history::insert_history_lines_to_writer(&mut term, &mut ansi, lines); + crate::insert_history::insert_history_lines(&mut term, lines); } - // 2) Render the ChatWidget UI into an off-screen buffer using WidgetRef directly - let mut ui_buf = Buffer::empty(viewport); - (&chat).render_ref(viewport, &mut ui_buf); + term.draw(|f| { + (&chat).render_ref(f.area(), f.buffer_mut()); + }) + .unwrap(); - // 3) Build VT100 visual from the captured ANSI - let mut parser = vt100::Parser::new(vt_height, width, 0); - parser.process(&ansi); - let mut vt_lines: Vec = (0..vt_height) - .map(|row| { - let mut s = String::with_capacity(width as usize); - for col in 0..width { - if let Some(cell) = parser.screen().cell(row, col) { - if let Some(ch) = cell.contents().chars().next() { - s.push(ch); - } else { - s.push(' '); - } - } else { - s.push(' '); - } - } - s.trim_end().to_string() - }) - .collect(); - - // 4) Overlay UI buffer content into the viewport region of the VT output - for rel_y in 0..viewport.height { - let y = viewport.y + rel_y; - let mut line = String::with_capacity(width as usize); - for x in 0..viewport.width { - let ch = ui_buf[(viewport.x + x, viewport.y + rel_y)] - .symbol() - .chars() - .next() - .unwrap_or(' '); - line.push(ch); - } - vt_lines[y as usize] = line.trim_end().to_string(); - } - - let visual = vt_lines.join("\n"); - assert_snapshot!(visual); + assert_snapshot!(term.backend().vt100().screen().contents()); } // E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks @@ -2182,13 +2114,11 @@ fn chatwidget_markdown_code_blocks_vt100_snapshot() { // Build a vt100 visual from the history insertions only (no UI overlay) let width: u16 = 80; let height: u16 = 50; - let backend = ratatui::backend::TestBackend::new(width, height); + 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)); - let mut ansi: Vec = Vec::new(); - // Simulate streaming via AgentMessageDelta in 2-character chunks (no final AgentMessage). let source: &str = r#" @@ -2234,9 +2164,7 @@ printf 'fenced within fenced\n' 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_to_writer( - &mut term, &mut ansi, lines, - ); + crate::insert_history::insert_history_lines(&mut term, lines); inserted_any = true; } } @@ -2254,34 +2182,8 @@ printf 'fenced within fenced\n' }), }); for lines in drain_insert_history(&mut rx) { - crate::insert_history::insert_history_lines_to_writer(&mut term, &mut ansi, lines); + crate::insert_history::insert_history_lines(&mut term, lines); } - let mut parser = vt100::Parser::new(height, width, 0); - parser.process(&ansi); - - let mut vt_lines: Vec = (0..height) - .map(|row| { - let mut s = String::with_capacity(width as usize); - for col in 0..width { - if let Some(cell) = parser.screen().cell(row, col) { - if let Some(ch) = cell.contents().chars().next() { - s.push(ch); - } else { - s.push(' '); - } - } else { - s.push(' '); - } - } - s.trim_end().to_string() - }) - .collect(); - - // Compact trailing blank rows for a stable snapshot - while matches!(vt_lines.last(), Some(l) if l.trim().is_empty()) { - vt_lines.pop(); - } - let visual = vt_lines.join("\n"); - assert_snapshot!(visual); + assert_snapshot!(term.backend().vt100().screen().contents()); } diff --git a/codex-rs/tui/src/color.rs b/codex-rs/tui/src/color.rs new file mode 100644 index 0000000000..f5121a1f6c --- /dev/null +++ b/codex-rs/tui/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/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 9de17412bd..3eddbec561 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -22,13 +22,25 @@ // 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 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)] @@ -90,7 +102,7 @@ impl Frame<'_> { #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] pub struct Terminal where - B: Backend, + B: Backend + Write, { /// The backend used to interface with the terminal backend: B, @@ -113,6 +125,7 @@ where impl Drop for Terminal where B: Backend, + B: Write, { #[allow(clippy::print_stderr)] fn drop(&mut self) { @@ -128,6 +141,7 @@ where 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 { @@ -176,11 +190,15 @@ where pub fn flush(&mut self) -> io::Result<()> { let previous_buffer = &self.buffers[1 - self.current]; let current_buffer = &self.buffers[self.current]; - let updates = previous_buffer.diff(current_buffer); - if let Some((col, row, _)) = updates.last() { - self.last_known_cursor_pos = Position { x: *col, y: *row }; + let updates = diff_buffers(previous_buffer, current_buffer); + if let Some(DrawCommand::Put { x, y, .. }) = updates + .iter() + .rev() + .find(|cmd| matches!(cmd, DrawCommand::Put { .. })) + { + self.last_known_cursor_pos = Position { x: *x, y: *y }; } - self.backend.draw(updates.into_iter()) + draw(&mut self.backend, updates.into_iter()) } /// Updates the Terminal so that internal buffers match the requested area. @@ -307,8 +325,7 @@ where self.swap_buffers(); - // Flush - self.backend.flush()?; + ratatui::backend::Backend::flush(&mut self.backend)?; Ok(()) } @@ -367,3 +384,189 @@ where self.backend.size() } } + +use ratatui::buffer::Cell; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug)] +enum DrawCommand<'a> { + Put { x: u16, y: u16, cell: &'a Cell }, + ClearToEnd { x: u16, y: u16, bg: Color }, +} + +fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { + let previous_buffer = &a.content; + let next_buffer = &b.content; + + let mut updates = vec![]; + let mut last_nonblank_column = 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); + + let x = row + .iter() + .rposition(|cell| cell.symbol() != " " || cell.bg != bg) + .unwrap_or(0); + last_nonblank_column[y as usize] = x as u16; + let (x_abs, y_abs) = a.pos_of(row_start + x + 1); + updates.push(DrawCommand::ClearToEnd { + x: x_abs, + y: y_abs, + bg, + }); + } + + // 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_column[row] { + updates.push(DrawCommand::Put { + x, + y, + cell: &next_buffer[i], + }); + } + } + + 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<'a, I>(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(()) + } +} diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index a096dbe534..27cb53d7f2 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -200,7 +200,7 @@ impl ExecCell { if self.is_active() { spinner(self.active_start_time()) } else { - "•".bold() + "•".into() }, " ".into(), if self.is_active() { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index d95663d9fc..941a7fbdda 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -9,9 +9,8 @@ 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; -pub(crate) use crate::status::RateLimitSnapshotDisplay; -pub(crate) use crate::status::new_status_output; -pub(crate) use crate::status::rate_limit_snapshot_display; +use crate::style::user_message_style; +use crate::terminal_palette::default_bg; use crate::text_formatting::format_and_truncate_tool_result; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::wrapping::RtOptions; @@ -97,17 +96,24 @@ impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); - // Wrap the content first, then prefix each wrapped line with the marker. + // Use ratatui-aware word wrapping and prefixing to avoid lifetime issues. let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS); // account for the ▌ prefix and trailing space - let wrapped = textwrap::wrap( - &self.message, - textwrap::Options::new(wrap_width as usize) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), // Match textarea wrap + + let style = user_message_style(default_bg()); + + // Use our ratatui wrapping helpers for correct styling and lifetimes. + let wrapped = word_wrap_lines( + &self + .message + .lines() + .map(|l| Line::from(l).style(style)) + .collect::>(), + RtOptions::new(wrap_width as usize), ); - for line in wrapped { - lines.push(vec!["▌ ".cyan().dim(), line.to_string().dim()].into()); - } + lines.push(Line::from("").style(style)); + lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); + lines.push(Line::from("").style(style)); lines } @@ -139,7 +145,21 @@ impl HistoryCell for ReasoningSummaryCell { let summary_lines = self .content .iter() - .map(|l| l.clone().dim().italic()) + .map(|line| { + Line::from( + line.spans + .iter() + .map(|span| { + Span::styled( + span.content.clone().into_owned(), + span.style + .add_modifier(Modifier::ITALIC) + .add_modifier(Modifier::DIM), + ) + }) + .collect::>(), + ) + }) .collect::>(); word_wrap_lines( @@ -179,7 +199,7 @@ impl HistoryCell for AgentMessageCell { &self.lines, RtOptions::new(width as usize) .initial_indent(if self.is_first_line { - "> ".into() + "• ".into() } else { " ".into() }) @@ -866,7 +886,7 @@ pub(crate) fn new_mcp_tools_output( } pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell { - let mut line = vec!["> ".into(), message.into()]; + let mut line = vec!["• ".into(), message.into()]; if let Some(hint) = hint { line.push(" ".into()); line.push(hint.dark_gray()); diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 75731c7f55..8f4bfa10be 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -2,7 +2,6 @@ use std::fmt; use std::io; use std::io::Write; -use crate::tui; use crate::wrapping::word_wrap_lines_borrowed; use crossterm::Command; use crossterm::cursor::MoveTo; @@ -14,7 +13,10 @@ use crossterm::style::SetAttribute; use crossterm::style::SetBackgroundColor; use crossterm::style::SetColors; use crossterm::style::SetForegroundColor; +use crossterm::terminal::Clear; +use crossterm::terminal::ClearType; use ratatui::layout::Size; +use ratatui::prelude::Backend; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::text::Line; @@ -22,24 +24,16 @@ use ratatui::text::Span; /// Insert `lines` above the viewport using the terminal's backend writer /// (avoids direct stdout references). -pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec) { - let mut out = std::io::stdout(); - insert_history_lines_to_writer(terminal, &mut out, lines); -} - -/// Like `insert_history_lines`, but writes ANSI to the provided writer. This -/// is intended for testing where a capture buffer is used instead of stdout. -pub fn insert_history_lines_to_writer( - terminal: &mut crate::custom_terminal::Terminal, - writer: &mut W, - lines: Vec, -) where - B: ratatui::backend::Backend, - W: Write, +pub fn insert_history_lines(terminal: &mut crate::custom_terminal::Terminal, lines: Vec) +where + B: Backend + Write, { let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); let mut area = terminal.viewport_area; + let mut should_update_area = false; + let last_cursor_pos = terminal.last_known_cursor_pos; + let writer = terminal.backend_mut(); // Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same // formatting as the TUI. This avoids character-level hard wrapping by the terminal. @@ -67,7 +61,7 @@ pub fn insert_history_lines_to_writer( let cursor_top = area.top().saturating_sub(1); area.y += scroll_amount; - terminal.set_viewport_area(area); + should_update_area = true; cursor_top } else { area.top().saturating_sub(1) @@ -97,6 +91,21 @@ pub fn insert_history_lines_to_writer( for line in wrapped { queue!(writer, Print("\r\n")).ok(); + queue!( + writer, + SetColors(Colors::new( + line.style + .fg + .map(std::convert::Into::into) + .unwrap_or(CColor::Reset), + line.style + .bg + .map(std::convert::Into::into) + .unwrap_or(CColor::Reset) + )) + ) + .ok(); + queue!(writer, Clear(ClearType::UntilNewLine)).ok(); // Merge line-level style into each span so that ANSI colors reflect // line styles (e.g., blockquotes with green fg). let merged_spans: Vec = line @@ -113,14 +122,12 @@ pub fn insert_history_lines_to_writer( queue!(writer, ResetScrollRegion).ok(); // Restore the cursor position to where it was before we started. - queue!( - writer, - MoveTo( - terminal.last_known_cursor_pos.x, - terminal.last_known_cursor_pos.y - ) - ) - .ok(); + queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y)).ok(); + + let _ = writer; + if should_update_area { + terminal.set_viewport_area(area); + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -275,9 +282,9 @@ where mod tests { use super::*; use crate::markdown_render::render_markdown_text; + use crate::test_backend::VT100Backend; use ratatui::layout::Rect; use ratatui::style::Color; - use vt100::Parser; #[test] fn writes_bold_then_regular_spans() { @@ -312,7 +319,7 @@ mod tests { // Set up a small off-screen terminal let width: u16 = 40; let height: u16 = 10; - let backend = ratatui::backend::TestBackend::new(width, height); + let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); // Place viewport on the last line so history inserts scroll upward let viewport = Rect::new(0, height - 1, width, 1); @@ -321,17 +328,12 @@ mod tests { // Build a blockquote-like line: apply line-level green style and prefix "> " let mut line: Line<'static> = Line::from(vec!["> ".into(), "Hello world".into()]); line = line.style(Color::Green); - let mut ansi: Vec = Vec::new(); - insert_history_lines_to_writer(&mut term, &mut ansi, vec![line]); - - // Parse ANSI using vt100 and assert at least one non-default fg color appears - let mut parser = Parser::new(height, width, 0); - parser.process(&ansi); + insert_history_lines(&mut term, vec![line]); let mut saw_colored = false; 'outer: for row in 0..height { for col in 0..width { - if let Some(cell) = parser.screen().cell(row, col) + if let Some(cell) = term.backend().vt100().screen().cell(row, col) && cell.has_contents() && cell.fgcolor() != vt100::Color::Default { @@ -351,7 +353,7 @@ mod tests { // Force wrapping by using a narrow viewport width and a long blockquote line. let width: u16 = 20; let height: u16 = 8; - let backend = ratatui::backend::TestBackend::new(width, height); + let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); // Viewport is the last line so history goes directly above it. let viewport = Rect::new(0, height - 1, width, 1); @@ -364,13 +366,10 @@ mod tests { ]); line = line.style(Color::Green); - let mut ansi: Vec = Vec::new(); - insert_history_lines_to_writer(&mut term, &mut ansi, vec![line]); + insert_history_lines(&mut term, vec![line]); // Parse and inspect the final screen buffer. - let mut parser = Parser::new(height, width, 0); - parser.process(&ansi); - let screen = parser.screen(); + let screen = term.backend().vt100().screen(); // Collect rows that are non-empty; these should correspond to our wrapped lines. let mut non_empty_rows: Vec = Vec::new(); @@ -418,7 +417,7 @@ mod tests { fn vt100_colored_prefix_then_plain_text_resets_color() { let width: u16 = 40; let height: u16 = 6; - let backend = ratatui::backend::TestBackend::new(width, height); + let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); let viewport = Rect::new(0, height - 1, width, 1); term.set_viewport_area(viewport); @@ -429,12 +428,9 @@ mod tests { Span::raw("Hello world"), ]); - let mut ansi: Vec = Vec::new(); - insert_history_lines_to_writer(&mut term, &mut ansi, vec![line]); + insert_history_lines(&mut term, vec![line]); - let mut parser = Parser::new(height, width, 0); - parser.process(&ansi); - let screen = parser.screen(); + let screen = term.backend().vt100().screen(); // Find the first non-empty row; verify first three cells are colored, following cells default. 'rows: for row in 0..height { @@ -483,35 +479,17 @@ mod tests { let width: u16 = 60; let height: u16 = 12; - let backend = ratatui::backend::TestBackend::new(width, height); + let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1); term.set_viewport_area(viewport); - let mut ansi: Vec = Vec::new(); - insert_history_lines_to_writer(&mut term, &mut ansi, lines); + insert_history_lines(&mut term, lines); - let mut parser = Parser::new(height, width, 0); - parser.process(&ansi); - let screen = parser.screen(); + let screen = term.backend().vt100().screen(); // Reconstruct screen rows as strings to locate the 3rd level line. - let mut rows: Vec = Vec::with_capacity(height as usize); - for row in 0..height { - let mut s = String::with_capacity(width as usize); - for col in 0..width { - if let Some(cell) = screen.cell(row, col) { - if let Some(ch) = cell.contents().chars().next() { - s.push(ch); - } else { - s.push(' '); - } - } else { - s.push(' '); - } - } - rows.push(s.trim_end().to_string()); - } + let rows: Vec = screen.rows(0, width).collect(); let needle = "1. Third level (ordered)"; let row_idx = rows diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index deeee6ae84..80f91d50b2 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -1,5 +1,5 @@ -use ratatui::style::Color; use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::text::Span; use std::fmt::Display; @@ -25,7 +25,7 @@ const SHIFT_PREFIX: &str = "⇧"; const SHIFT_PREFIX: &str = "Shift+"; fn key_hint_style() -> Style { - Style::default().fg(Color::Cyan) + Style::default().bold() } fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 25eec9d86e..4c5e7ee97c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -39,6 +39,7 @@ mod chatwidget; mod citation_regex; mod cli; mod clipboard_paste; +mod color; pub mod custom_terminal; mod diff_render; mod exec_cell; @@ -64,12 +65,17 @@ mod slash_command; mod status; mod status_indicator_widget; mod streaming; +mod style; +mod terminal_palette; mod text_formatting; mod tui; mod ui_consts; mod version; mod wrapping; +#[cfg(test)] +pub mod test_backend; + #[cfg(not(debug_assertions))] mod updates; diff --git a/codex-rs/tui/src/render/line_utils.rs b/codex-rs/tui/src/render/line_utils.rs index 5a8e09068a..175b79b2a8 100644 --- a/codex-rs/tui/src/render/line_utils.rs +++ b/codex-rs/tui/src/render/line_utils.rs @@ -53,7 +53,7 @@ pub fn prefix_lines( subsequent_prefix.clone() }); spans.extend(l.spans); - Line::from(spans) + Line::from(spans).style(l.style) }) .collect() } diff --git a/codex-rs/tui/src/shimmer.rs b/codex-rs/tui/src/shimmer.rs index b5752fc3d8..a6a16f2ce6 100644 --- a/codex-rs/tui/src/shimmer.rs +++ b/codex-rs/tui/src/shimmer.rs @@ -7,6 +7,12 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Span; +use crate::color::blend; +use crate::terminal_palette::default_fg; +use crate::terminal_palette::terminal_palette; + +const FALLBACK_DARK_GRAY: (u8, u8, u8) = (103, 103, 103); + static PROCESS_START: OnceLock = OnceLock::new(); fn elapsed_since_start() -> Duration { @@ -32,6 +38,8 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> { let band_half_width = 3.0; let mut spans: Vec> = Vec::with_capacity(chars.len()); + let default_fg = default_fg(); + let palette_dark_gray = terminal_palette().map(|palette| palette[8]); for (i, ch) in chars.iter().enumerate() { let i_pos = i as isize + padding as isize; let pos = pos as isize; @@ -43,31 +51,33 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> { } else { 0.0 }; - let brightness = 0.4 + 0.6 * t; - let level = (brightness * 255.0).clamp(0.0, 255.0) as u8; let style = if has_true_color { + let base = palette_dark_gray + .or(default_fg) + .unwrap_or(FALLBACK_DARK_GRAY); + let highlight = t.clamp(0.0, 1.0); + let (r, g, b) = blend((255, 255, 255), base, highlight); // Allow custom RGB colors, as the implementation is thoughtfully // adjusting the level of the default foreground color. #[allow(clippy::disallowed_methods)] { Style::default() - .fg(Color::Rgb(level, level, level)) + .fg(Color::Rgb(r, g, b)) .add_modifier(Modifier::BOLD) } } else { - color_for_level(level) + color_for_level(t) }; spans.push(Span::styled(ch.to_string(), style)); } spans } -fn color_for_level(level: u8) -> Style { - // Tune thresholds so the edges of the shimmer band appear dim - // in fallback mode (no true color support). - if level < 160 { +fn color_for_level(intensity: f32) -> Style { + // Tune fallback styling so the shimmer band reads even without RGB support. + if intensity < 0.2 { Style::default().add_modifier(Modifier::DIM) - } else if level < 224 { + } else if intensity < 0.6 { Style::default() } else { Style::default().add_modifier(Modifier::BOLD) diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap index ef0ceabc48..3fd59095ce 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap @@ -2,7 +2,7 @@ source: tui/src/history_cell.rs expression: rendered --- -▌ one two -▌ three four -▌ five six -▌ seven +› one two + three four + five six + seven diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs new file mode 100644 index 0000000000..90a8e7a85c --- /dev/null +++ b/codex-rs/tui/src/style.rs @@ -0,0 +1,44 @@ +use crate::color::blend; +use crate::color::is_light; +use crate::color::perceptual_distance; +use crate::terminal_palette::terminal_palette; +use ratatui::style::Color; +use ratatui::style::Style; + +/// Returns the style for a user-authored message using the provided terminal background. +pub fn user_message_style(terminal_bg: Option<(u8, u8, u8)>) -> Style { + match terminal_bg { + Some(bg) => Style::default().bg(user_message_bg(bg)), + None => Style::default(), + } +} + +#[allow(clippy::disallowed_methods)] +pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { + let top = if is_light(terminal_bg) { + (0, 0, 0) + } else { + (255, 255, 255) + }; + let bottom = terminal_bg; + let Some(color_level) = supports_color::on_cached(supports_color::Stream::Stdout) else { + return Color::default(); + }; + + let target = blend(top, bottom, 0.1); + if color_level.has_16m { + let (r, g, b) = target; + Color::Rgb(r, g, b) + } else if color_level.has_256 + && let Some(palette) = terminal_palette() + && let Some((i, _)) = palette.into_iter().enumerate().min_by(|(_, a), (_, b)| { + perceptual_distance(*a, target) + .partial_cmp(&perceptual_distance(*b, target)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + { + Color::Indexed(i as u8) + } else { + Color::default() + } +} diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs new file mode 100644 index 0000000000..499fe3e097 --- /dev/null +++ b/codex-rs/tui/src/terminal_palette.rs @@ -0,0 +1,398 @@ +pub fn terminal_palette() -> Option<[(u8, u8, u8); 256]> { + imp::terminal_palette() +} + +#[derive(Clone, Copy)] +pub struct DefaultColors { + #[allow(dead_code)] + fg: (u8, u8, u8), + bg: (u8, u8, u8), +} + +pub fn default_colors() -> Option<&'static DefaultColors> { + imp::default_colors() +} + +#[allow(dead_code)] +pub fn default_fg() -> Option<(u8, u8, u8)> { + default_colors().map(|c| c.fg) +} + +pub fn default_bg() -> Option<(u8, u8, u8)> { + default_colors().map(|c| c.bg) +} + +#[cfg(all(unix, not(test)))] +mod imp { + use super::DefaultColors; + use std::mem::MaybeUninit; + use std::os::fd::RawFd; + use std::sync::OnceLock; + + pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> { + static CACHE: OnceLock> = OnceLock::new(); + *CACHE.get_or_init(|| match query_terminal_palette() { + Ok(Some(palette)) => Some(palette), + _ => None, + }) + } + + pub(super) fn default_colors() -> Option<&'static DefaultColors> { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| query_default_colors().unwrap_or_default()) + .as_ref() + } + + #[allow(dead_code)] + fn query_terminal_palette() -> std::io::Result> { + use std::fs::OpenOptions; + use std::io::ErrorKind; + use std::io::IsTerminal; + use std::io::Read; + use std::io::Write; + use std::os::fd::AsRawFd; + use std::time::Duration; + use std::time::Instant; + + if !std::io::stdout().is_terminal() { + return Ok(None); + } + + let mut tty = match OpenOptions::new().read(true).write(true).open("/dev/tty") { + Ok(file) => file, + Err(_) => return Ok(None), + }; + + for index in 0..256 { + write!(tty, "\x1b]4;{index};?\x07")?; + } + tty.flush()?; + + let fd = tty.as_raw_fd(); + let _termios_guard = unsafe { suppress_echo(fd) }; + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + + let mut palette: [Option<(u8, u8, u8)>; 256] = [None; 256]; + let mut buffer = Vec::new(); + let mut remaining = palette.len(); + let read_deadline = Instant::now() + Duration::from_millis(1500); + + while remaining > 0 && Instant::now() < read_deadline { + let mut chunk = [0u8; 512]; + match tty.read(&mut chunk) { + Ok(0) => break, + Ok(read) => { + buffer.extend_from_slice(&chunk[..read]); + let newly = apply_palette_responses(&mut buffer, &mut palette); + if newly > 0 { + remaining = remaining.saturating_sub(newly); + } + } + Err(err) if err.kind() == ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(err) if err.kind() == ErrorKind::Interrupted => continue, + Err(_) => return Ok(None), + } + } + + remaining = remaining.saturating_sub(apply_palette_responses(&mut buffer, &mut palette)); + remaining = remaining.saturating_sub(drain_remaining(&mut tty, &mut buffer, &mut palette)); + + if remaining > 0 { + return Ok(None); + } + + let mut colors = [(0, 0, 0); 256]; + for (slot, value) in colors.iter_mut().zip(palette.into_iter()) { + if let Some(rgb) = value { + *slot = rgb; + } else { + return Ok(None); + } + } + + Ok(Some(colors)) + } + + #[allow(dead_code)] + fn query_default_colors() -> std::io::Result> { + use std::fs::OpenOptions; + use std::io::ErrorKind; + use std::io::IsTerminal; + use std::io::Read; + use std::io::Write; + use std::os::fd::AsRawFd; + use std::time::Duration; + use std::time::Instant; + + let mut stdout_handle = std::io::stdout(); + if !stdout_handle.is_terminal() { + return Ok(None); + } + stdout_handle.write_all(b"\x1b]10;?\x07\x1b]11;?\x07")?; + stdout_handle.flush()?; + + let mut tty = match OpenOptions::new().read(true).open("/dev/tty") { + Ok(file) => file, + Err(_) => return Ok(None), + }; + + let fd = tty.as_raw_fd(); + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + + let deadline = Instant::now() + Duration::from_millis(200); + let mut buffer = Vec::new(); + let mut fg = None; + let mut bg = None; + + while Instant::now() < deadline { + let mut chunk = [0u8; 128]; + match tty.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + buffer.extend_from_slice(&chunk[..n]); + if fg.is_none() { + fg = parse_osc_color(&buffer, 10); + } + if bg.is_none() { + bg = parse_osc_color(&buffer, 11); + } + if let (Some(fg), Some(bg)) = (fg, bg) { + return Ok(Some(DefaultColors { fg, bg })); + } + } + Err(err) if err.kind() == ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(err) if err.kind() == ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + + if fg.is_none() { + fg = parse_osc_color(&buffer, 10); + } + if bg.is_none() { + bg = parse_osc_color(&buffer, 11); + } + + Ok(fg.zip(bg).map(|(fg, bg)| DefaultColors { fg, bg })) + } + + fn drain_remaining( + tty: &mut std::fs::File, + buffer: &mut Vec, + palette: &mut [Option<(u8, u8, u8)>; 256], + ) -> usize { + use std::io::ErrorKind; + use std::io::Read; + use std::time::Duration; + use std::time::Instant; + + let mut chunk = [0u8; 512]; + let mut idle_deadline = Instant::now() + Duration::from_millis(50); + let mut newly_filled = 0usize; + + loop { + match tty.read(&mut chunk) { + Ok(0) => break, + Ok(read) => { + buffer.extend_from_slice(&chunk[..read]); + newly_filled += apply_palette_responses(buffer, palette); + idle_deadline = Instant::now() + Duration::from_millis(50); + } + Err(err) if err.kind() == ErrorKind::WouldBlock => { + if Instant::now() >= idle_deadline { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(err) if err.kind() == ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + + buffer.clear(); + newly_filled + } + + struct TermiosGuard { + fd: RawFd, + original: libc::termios, + } + + impl Drop for TermiosGuard { + fn drop(&mut self) { + unsafe { + libc::tcsetattr(self.fd, libc::TCSANOW, &self.original); + } + } + } + + unsafe fn suppress_echo(fd: RawFd) -> Option { + let mut termios = MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(fd, termios.as_mut_ptr()) } != 0 { + return None; + } + let termios = unsafe { termios.assume_init() }; + let mut modified = termios; + modified.c_lflag &= !(libc::ECHO | libc::ECHONL); + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &modified) } != 0 { + return None; + } + Some(TermiosGuard { + fd, + original: termios, + }) + } + + fn apply_palette_responses( + buffer: &mut Vec, + palette: &mut [Option<(u8, u8, u8)>; 256], + ) -> usize { + let mut newly_filled = 0; + + while let Some(start) = buffer.windows(2).position(|window| window == [0x1b, b']']) { + if start > 0 { + buffer.drain(..start); + continue; + } + + let mut index = 2; // skip ESC ] + let mut terminator_len = None; + while index < buffer.len() { + match buffer[index] { + 0x07 => { + terminator_len = Some(1); + break; + } + 0x1b if index + 1 < buffer.len() && buffer[index + 1] == b'\\' => { + terminator_len = Some(2); + break; + } + _ => index += 1, + } + } + + let Some(terminator_len) = terminator_len else { + break; + }; + + let end = index; + let parsed = std::str::from_utf8(&buffer[2..end]) + .ok() + .and_then(parse_palette_message); + let processed = end + terminator_len; + buffer.drain(..processed); + + if let Some((slot, color)) = parsed + && palette[slot].is_none() + { + palette[slot] = Some(color); + newly_filled += 1; + } + } + + newly_filled + } + + fn parse_palette_message(message: &str) -> Option<(usize, (u8, u8, u8))> { + let mut parts = message.splitn(3, ';'); + if parts.next()? != "4" { + return None; + } + let index: usize = parts.next()?.trim().parse().ok()?; + if index >= 256 { + return None; + } + let payload = parts.next()?; + let (model, values) = payload.split_once(':')?; + if model != "rgb" && model != "rgba" { + return None; + } + let mut components = values.split('/'); + let r = parse_component(components.next()?)?; + let g = parse_component(components.next()?)?; + let b = parse_component(components.next()?)?; + Some((index, (r, g, b))) + } + + fn parse_component(component: &str) -> Option { + let trimmed = component.trim(); + if trimmed.is_empty() { + return None; + } + let bits = trimmed.len().checked_mul(4)?; + if bits == 0 || bits > 64 { + return None; + } + let max = if bits == 64 { + u64::MAX + } else { + (1u64 << bits) - 1 + }; + let value = u64::from_str_radix(trimmed, 16).ok()?; + Some(((value * 255 + max / 2) / max) as u8) + } + + fn parse_osc_color(buffer: &[u8], code: u8) -> Option<(u8, u8, u8)> { + let text = std::str::from_utf8(buffer).ok()?; + let prefix = match code { + 10 => "\u{1b}]10;", + 11 => "\u{1b}]11;", + _ => return None, + }; + let start = text.rfind(prefix)?; + let after_prefix = &text[start + prefix.len()..]; + let end_bel = after_prefix.find('\u{7}'); + let end_st = after_prefix.find("\u{1b}\\"); + let end_idx = match (end_bel, end_st) { + (Some(bel), Some(st)) => bel.min(st), + (Some(bel), None) => bel, + (None, Some(st)) => st, + (None, None) => return None, + }; + let payload = after_prefix[..end_idx].trim(); + parse_color_payload(payload) + } + + fn parse_color_payload(payload: &str) -> Option<(u8, u8, u8)> { + if payload.is_empty() || payload == "?" { + return None; + } + let (model, values) = payload.split_once(':')?; + if model != "rgb" && model != "rgba" { + return None; + } + let mut parts = values.split('/'); + let r = parse_component(parts.next()?)?; + let g = parse_component(parts.next()?)?; + let b = parse_component(parts.next()?)?; + Some((r, g, b)) + } +} + +#[cfg(not(all(unix, not(test))))] +mod imp { + use super::DefaultColors; + + pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> { + None + } + + pub(super) fn default_colors() -> Option<&'static DefaultColors> { + None + } +} diff --git a/codex-rs/tui/src/test_backend.rs b/codex-rs/tui/src/test_backend.rs new file mode 100644 index 0000000000..52e7254711 --- /dev/null +++ b/codex-rs/tui/src/test_backend.rs @@ -0,0 +1,123 @@ +use std::fmt::{self}; +use std::io::Write; +use std::io::{self}; + +use ratatui::prelude::CrosstermBackend; + +use ratatui::backend::Backend; +use ratatui::backend::ClearType; +use ratatui::backend::WindowSize; +use ratatui::buffer::Cell; +use ratatui::layout::Position; +use ratatui::layout::Size; + +/// This wraps a CrosstermBackend and a vt100::Parser to mock +/// a "real" terminal. +/// +/// Importantly, this wrapper avoids calling any crossterm methods +/// which write to stdout regardless of the writer. This includes: +/// - getting the terminal size +/// - getting the cursor position +pub struct VT100Backend { + crossterm_backend: CrosstermBackend, +} + +impl VT100Backend { + /// Creates a new `TestBackend` with the specified width and height. + pub fn new(width: u16, height: u16) -> Self { + Self { + crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)), + } + } + + pub fn vt100(&self) -> &vt100::Parser { + self.crossterm_backend.writer() + } +} + +impl Write for VT100Backend { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.crossterm_backend.writer_mut().write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.crossterm_backend.writer_mut().flush() + } +} + +impl fmt::Display for VT100Backend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.crossterm_backend.writer().screen().contents()) + } +} + +impl Backend for VT100Backend { + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + self.crossterm_backend.draw(content)?; + Ok(()) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + self.crossterm_backend.hide_cursor()?; + Ok(()) + } + + fn show_cursor(&mut self) -> io::Result<()> { + self.crossterm_backend.show_cursor()?; + Ok(()) + } + + fn get_cursor_position(&mut self) -> io::Result { + Ok(self.vt100().screen().cursor_position().into()) + } + + fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + self.crossterm_backend.set_cursor_position(position) + } + + fn clear(&mut self) -> io::Result<()> { + self.crossterm_backend.clear() + } + + fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { + self.crossterm_backend.clear_region(clear_type) + } + + fn append_lines(&mut self, line_count: u16) -> io::Result<()> { + self.crossterm_backend.append_lines(line_count) + } + + fn size(&self) -> io::Result { + Ok(self.vt100().screen().size().into()) + } + + fn window_size(&mut self) -> io::Result { + Ok(WindowSize { + columns_rows: self.vt100().screen().size().into(), + // Arbitrary size, we don't rely on this in testing. + pixels: Size { + width: 640, + height: 480, + }, + }) + } + + fn flush(&mut self) -> io::Result<()> { + self.crossterm_backend.writer_mut().flush() + } + + fn scroll_region_up(&mut self, region: std::ops::Range, scroll_by: u16) -> io::Result<()> { + self.crossterm_backend.scroll_region_up(region, scroll_by) + } + + fn scroll_region_down( + &mut self, + region: std::ops::Range, + scroll_by: u16, + ) -> io::Result<()> { + self.crossterm_backend.scroll_region_down(region, scroll_by) + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index eeabec4e04..8db728f215 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -1,3 +1,4 @@ +use std::io::IsTerminal; use std::io::Result; use std::io::Stdout; use std::io::stdout; @@ -123,6 +124,9 @@ pub fn restore() -> Result<()> { /// Initialize the terminal (inline viewport; history stays in normal scrollback) pub fn init() -> Result { + if !stdout().is_terminal() { + return Err(std::io::Error::other("stdout is not a terminal")); + } set_modes()?; set_panic_hook(); @@ -271,6 +275,10 @@ impl Tui { // Detect keyboard enhancement support before any EventStream is created so the // crossterm poller can acquire its lock without contention. let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); + // Cache this to avoid contention with the event reader. + supports_color::on_cached(supports_color::Stream::Stdout); + let _ = crate::terminal_palette::terminal_palette(); + let _ = crate::terminal_palette::default_colors(); Self { frame_schedule_tx, diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index bd3c451bf9..da79f036b4 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -186,7 +186,7 @@ where }; // Build first wrapped line with initial indent. - let mut first_line = rt_opts.initial_indent.clone(); + let mut first_line = rt_opts.initial_indent.clone().style(line.style); { let sliced = slice_line_spans(line, &span_bounds, first_line_range); let mut spans = first_line.spans; @@ -214,7 +214,7 @@ where if r.is_empty() { continue; } - let mut subsequent_line = rt_opts.subsequent_indent.clone(); + let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style); let offset_range = (r.start + base)..(r.end + base); let sliced = slice_line_spans(line, &span_bounds, &offset_range); let mut spans = subsequent_line.spans; diff --git a/codex-rs/tui/tests/all.rs b/codex-rs/tui/tests/all.rs index 7e136e4cce..beb7960879 100644 --- a/codex-rs/tui/tests/all.rs +++ b/codex-rs/tui/tests/all.rs @@ -1,3 +1,6 @@ // Single integration test binary that aggregates all test modules. // The submodules live in `tests/suite/`. +#[cfg(feature = "vt100-tests")] +mod test_backend; + mod suite; diff --git a/codex-rs/tui/tests/suite/mod.rs b/codex-rs/tui/tests/suite/mod.rs index d120546c71..944e338769 100644 --- a/codex-rs/tui/tests/suite/mod.rs +++ b/codex-rs/tui/tests/suite/mod.rs @@ -2,4 +2,3 @@ mod status_indicator; mod vt100_history; mod vt100_live_commit; -mod vt100_streaming_no_dup; diff --git a/codex-rs/tui/tests/suite/vt100_history.rs b/codex-rs/tui/tests/suite/vt100_history.rs index 973b0aa052..a4c0e38783 100644 --- a/codex-rs/tui/tests/suite/vt100_history.rs +++ b/codex-rs/tui/tests/suite/vt100_history.rs @@ -1,7 +1,7 @@ #![cfg(feature = "vt100-tests")] #![expect(clippy::expect_used)] -use ratatui::backend::TestBackend; +use crate::test_backend::VT100Backend; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; @@ -23,52 +23,20 @@ macro_rules! assert_contains { } struct TestScenario { - width: u16, - height: u16, - term: codex_tui::custom_terminal::Terminal, + term: codex_tui::custom_terminal::Terminal, } impl TestScenario { fn new(width: u16, height: u16, viewport: Rect) -> Self { - let backend = TestBackend::new(width, height); + let backend = VT100Backend::new(width, height); let mut term = codex_tui::custom_terminal::Terminal::with_options(backend) .expect("failed to construct terminal"); term.set_viewport_area(viewport); - Self { - width, - height, - term, - } + Self { term } } - fn run_insert(&mut self, lines: Vec>) -> Vec { - let mut buf: Vec = Vec::new(); - codex_tui::insert_history::insert_history_lines_to_writer(&mut self.term, &mut buf, lines); - buf - } - - fn screen_rows_from_bytes(&self, bytes: &[u8]) -> Vec { - let mut parser = vt100::Parser::new(self.height, self.width, 0); - parser.process(bytes); - let screen = parser.screen(); - - let mut rows: Vec = Vec::with_capacity(self.height as usize); - for row in 0..self.height { - let mut s = String::with_capacity(self.width as usize); - for col in 0..self.width { - if let Some(cell) = screen.cell(row, col) { - if let Some(ch) = cell.contents().chars().next() { - s.push(ch); - } else { - s.push(' '); - } - } else { - s.push(' '); - } - } - rows.push(s.trim_end().to_string()); - } - rows + fn run_insert(&mut self, lines: Vec>) { + codex_tui::insert_history::insert_history_lines(&mut self.term, lines); } } @@ -79,19 +47,10 @@ fn basic_insertion_no_wrap() { let mut scenario = TestScenario::new(20, 6, area); let lines = vec!["first".into(), "second".into()]; - let buf = scenario.run_insert(lines); - let rows = scenario.screen_rows_from_bytes(&buf); + scenario.run_insert(lines); + let rows = scenario.term.backend().vt100().screen().contents(); assert_contains!(rows, String::from("first")); assert_contains!(rows, String::from("second")); - let first_idx = rows - .iter() - .position(|r| r == "first") - .expect("expected 'first' row to be present"); - let second_idx = rows - .iter() - .position(|r| r == "second") - .expect("expected 'second' row to be present"); - assert_eq!(second_idx, first_idx + 1, "rows should be adjacent"); } #[test] @@ -101,10 +60,8 @@ fn long_token_wraps() { let long = "A".repeat(45); // > 2 lines at width 20 let lines = vec![long.clone().into()]; - let buf = scenario.run_insert(lines); - let mut parser = vt100::Parser::new(6, 20, 0); - parser.process(&buf); - let screen = parser.screen(); + scenario.run_insert(lines); + let screen = scenario.term.backend().vt100().screen(); // Count total A's on the screen let mut count_a = 0usize; @@ -133,12 +90,11 @@ fn emoji_and_cjk() { let text = String::from("😀😀😀😀😀 你好世界"); let lines = vec![text.clone().into()]; - let buf = scenario.run_insert(lines); - let rows = scenario.screen_rows_from_bytes(&buf); - let reconstructed: String = rows.join("").chars().filter(|c| *c != ' ').collect(); + scenario.run_insert(lines); + let rows = scenario.term.backend().vt100().screen().contents(); for ch in text.chars().filter(|c| !c.is_whitespace()) { assert!( - reconstructed.contains(ch), + rows.contains(ch), "missing character {ch:?} in reconstructed screen" ); } @@ -150,8 +106,8 @@ fn mixed_ansi_spans() { let mut scenario = TestScenario::new(20, 6, area); let line = vec!["red".red(), "+plain".into()].into(); - let buf = scenario.run_insert(vec![line]); - let rows = scenario.screen_rows_from_bytes(&buf); + scenario.run_insert(vec![line]); + let rows = scenario.term.backend().vt100().screen().contents(); assert_contains!(rows, String::from("red+plain")); } @@ -161,18 +117,8 @@ fn cursor_restoration() { let mut scenario = TestScenario::new(20, 6, area); let lines = vec!["x".into()]; - let buf = scenario.run_insert(lines); - let s = String::from_utf8_lossy(&buf); - // CUP to 1;1 (ANSI: ESC[1;1H) - assert!( - s.contains("\u{1b}[1;1H"), - "expected final CUP to 1;1 in output, got: {s:?}" - ); - // Reset scroll region - assert!( - s.contains("\u{1b}[r"), - "expected reset scroll region in output, got: {s:?}" - ); + scenario.run_insert(lines); + assert_eq!(scenario.term.last_known_cursor_pos, (0, 0).into()); } #[test] @@ -182,9 +128,8 @@ fn word_wrap_no_mid_word_split() { let mut scenario = TestScenario::new(40, 10, area); let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them."; - let buf = scenario.run_insert(vec![sample.into()]); - let rows = scenario.screen_rows_from_bytes(&buf); - let joined = rows.join("\n"); + scenario.run_insert(vec![sample.into()]); + let joined = scenario.term.backend().vt100().screen().contents(); assert!( !joined.contains("bo\nth"), "word 'both' should not be split across lines:\n{joined}" @@ -198,43 +143,10 @@ fn em_dash_and_space_word_wrap() { let mut scenario = TestScenario::new(40, 10, area); let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed."; - let buf = scenario.run_insert(vec![sample.into()]); - let rows = scenario.screen_rows_from_bytes(&buf); - let joined = rows.join("\n"); + scenario.run_insert(vec![sample.into()]); + let joined = scenario.term.backend().vt100().screen().contents(); assert!( !joined.contains("insi\nde"), "word 'inside' should not be split across lines:\n{joined}" ); } - -#[test] -fn pre_scroll_region_down() { - // Viewport not at bottom: y=3 (0-based), height=1 - let area = Rect::new(0, 3, 20, 1); - let mut scenario = TestScenario::new(20, 6, area); - - let lines = vec!["first".into(), "second".into()]; - let buf = scenario.run_insert(lines); - let s = String::from_utf8_lossy(&buf); - // Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based) - assert!( - s.contains("\u{1b}[4;6r"), - "expected pre-scroll SetScrollRegion 4..6, got: {s:?}" - ); - // Expect we moved cursor to top of that region: row 3 (0-based) => CUP 4;1H - assert!( - s.contains("\u{1b}[4;1H"), - "expected cursor at top of pre-scroll region, got: {s:?}" - ); - // Expect at least two Reverse Index commands (ESC M) for two inserted lines - let ri_count = s.matches("\u{1b}M").count(); - assert!( - ri_count >= 1, - "expected at least one RI (ESC M), got: {s:?}" - ); - // After pre-scroll, we set insertion scroll region to [1 .. new_top] => [1 .. 5] - assert!( - s.contains("\u{1b}[1;5r"), - "expected insertion SetScrollRegion 1..5, got: {s:?}" - ); -} diff --git a/codex-rs/tui/tests/suite/vt100_live_commit.rs b/codex-rs/tui/tests/suite/vt100_live_commit.rs index e06f10275c..e9689cf5c9 100644 --- a/codex-rs/tui/tests/suite/vt100_live_commit.rs +++ b/codex-rs/tui/tests/suite/vt100_live_commit.rs @@ -1,12 +1,12 @@ #![cfg(feature = "vt100-tests")] -use ratatui::backend::TestBackend; +use crate::test_backend::VT100Backend; use ratatui::layout::Rect; use ratatui::text::Line; #[test] fn live_001_commit_on_overflow() { - let backend = TestBackend::new(20, 6); + let backend = VT100Backend::new(20, 6); let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) { Ok(t) => t, Err(e) => panic!("failed to construct terminal: {e}"), @@ -26,27 +26,12 @@ fn live_001_commit_on_overflow() { let commit_rows = rb.drain_commit_ready(3); let lines: Vec> = commit_rows.into_iter().map(|r| r.text.into()).collect(); - let mut buf: Vec = Vec::new(); - codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); + codex_tui::insert_history::insert_history_lines(&mut term, lines); - let mut parser = vt100::Parser::new(6, 20, 0); - parser.process(&buf); - let screen = parser.screen(); + let screen = term.backend().vt100().screen(); // The words "one" and "two" should appear above the viewport. - let mut joined = String::new(); - for row in 0..6 { - for col in 0..20 { - if let Some(cell) = screen.cell(row, col) { - if let Some(ch) = cell.contents().chars().next() { - joined.push(ch); - } else { - joined.push(' '); - } - } - } - joined.push('\n'); - } + let joined = screen.contents(); assert!( joined.contains("one"), "expected committed 'one' to be visible\n{joined}" @@ -57,39 +42,3 @@ fn live_001_commit_on_overflow() { ); // The last three (three,four,five) remain in the live ring, not committed here. } - -#[test] -fn live_002_pre_scroll_and_commit() { - let backend = TestBackend::new(20, 6); - let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) { - Ok(t) => t, - Err(e) => panic!("failed to construct terminal: {e}"), - }; - // Viewport not at bottom: y=3 - let area = Rect::new(0, 3, 20, 1); - term.set_viewport_area(area); - - let mut rb = codex_tui::live_wrap::RowBuilder::new(20); - rb.push_fragment("alpha\n"); - rb.push_fragment("beta\n"); - rb.push_fragment("gamma\n"); - rb.push_fragment("delta\n"); - - // Keep 3, commit 1. - let commit_rows = rb.drain_commit_ready(3); - let lines: Vec> = commit_rows.into_iter().map(|r| r.text.into()).collect(); - - let mut buf: Vec = Vec::new(); - codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); - let s = String::from_utf8_lossy(&buf); - - // Expect a SetScrollRegion to [area.top()+1 .. screen_height] and a cursor move to top of that region. - assert!( - s.contains("\u{1b}[4;6r"), - "expected pre-scroll region 4..6, got: {s:?}" - ); - assert!( - s.contains("\u{1b}[4;1H"), - "expected cursor CUP 4;1H, got: {s:?}" - ); -} diff --git a/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs b/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs deleted file mode 100644 index 79f9d2fb00..0000000000 --- a/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs +++ /dev/null @@ -1,76 +0,0 @@ -#![cfg(feature = "vt100-tests")] - -use ratatui::backend::TestBackend; -use ratatui::layout::Rect; - -fn term(viewport: Rect) -> codex_tui::custom_terminal::Terminal { - let backend = TestBackend::new(20, 6); - let mut term = codex_tui::custom_terminal::Terminal::with_options(backend) - .unwrap_or_else(|e| panic!("failed to construct terminal: {e}")); - term.set_viewport_area(viewport); - term -} - -#[test] -fn stream_commit_trickle_no_duplication() { - // Viewport is the last row (height=1 at y=5) - let area = Rect::new(0, 5, 20, 1); - let mut t = term(area); - - // Step 1: commit first row - let mut out1 = Vec::new(); - codex_tui::insert_history::insert_history_lines_to_writer( - &mut t, - &mut out1, - vec!["one".into()], - ); - - // Step 2: later commit next row - let mut out2 = Vec::new(); - codex_tui::insert_history::insert_history_lines_to_writer( - &mut t, - &mut out2, - vec!["two".into()], - ); - - let combined = [out1, out2].concat(); - let s = String::from_utf8_lossy(&combined); - assert_eq!( - s.matches("one").count(), - 1, - "history line duplicated: {s:?}" - ); - assert_eq!( - s.matches("two").count(), - 1, - "history line duplicated: {s:?}" - ); - assert!( - !s.contains("three"), - "live-only content leaked into history: {s:?}" - ); -} - -#[test] -fn live_ring_rows_not_inserted_into_history() { - let area = Rect::new(0, 5, 20, 1); - let mut t = term(area); - - // Commit two rows to history. - let mut buf = Vec::new(); - codex_tui::insert_history::insert_history_lines_to_writer( - &mut t, - &mut buf, - vec!["one".into(), "two".into()], - ); - - // The live ring might display tail+head rows like ["two", "three"], - // but only committed rows should be present in the history ANSI stream. - let s = String::from_utf8_lossy(&buf); - assert!(s.contains("one")); - assert!(s.contains("two")); - assert!( - !s.contains("three"), - "uncommitted live-ring content should not be inserted into history: {s:?}" - ); -} diff --git a/codex-rs/tui/tests/test_backend.rs b/codex-rs/tui/tests/test_backend.rs new file mode 100644 index 0000000000..299fb33f78 --- /dev/null +++ b/codex-rs/tui/tests/test_backend.rs @@ -0,0 +1,4 @@ +#[path = "../src/test_backend.rs"] +mod inner; + +pub use inner::VT100Backend; From 3a1be084f911badd12a1fd1c1c6ca1b6c14eaa03 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Fri, 26 Sep 2025 18:24:01 -0700 Subject: [PATCH 14/31] [MCP] Add experimental support for streamable HTTP MCP servers (#4317) This PR adds support for streamable HTTP MCP servers when the `experimental_use_rmcp_client` is enabled. To set one up, simply add a new mcp server config with the url: ``` [mcp_servers.figma] url = "http://127.0.0.1:3845/mcp" ``` It also supports an optional `bearer_token` which will be provided in an authorization header. The full oauth flow is not supported yet. The config parsing will throw if it detects that the user mixed and matched config fields (like command + bearer token or url + env). The best way to review it is to review `core/src` and then `rmcp-client/src/rmcp_client.rs` first. The rest is tests and propagating the `Transport` struct around the codebase. Example with the Figma MCP: CleanShot 2025-09-26 at 13 35 40 --- codex-rs/Cargo.lock | 81 +++++- codex-rs/cli/src/mcp_cmd.rs | 243 +++++++++++------ codex-rs/cli/tests/mcp_add_remove.rs | 17 +- codex-rs/cli/tests/mcp_list.rs | 44 ++-- codex-rs/core/src/config.rs | 193 ++++++++++++-- codex-rs/core/src/config_types.rs | 226 +++++++++++++++- codex-rs/core/src/mcp_connection_manager.rs | 65 +++-- codex-rs/core/tests/suite/rmcp_client.rs | 249 ++++++++++++++++-- codex-rs/justfile | 4 + codex-rs/rmcp-client/Cargo.toml | 9 + .../rmcp-client/src/bin/test_stdio_server.rs | 142 ++++++++++ .../src/bin/test_streamable_http_server.rs | 167 ++++++++++++ codex-rs/rmcp-client/src/rmcp_client.rs | 36 ++- codex-rs/tui/src/history_cell.rs | 18 +- 14 files changed, 1304 insertions(+), 190 deletions(-) create mode 100644 codex-rs/rmcp-client/src/bin/test_stdio_server.rs create mode 100644 codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ac792eaf3b..eb97878072 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -333,6 +333,54 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -956,8 +1004,11 @@ name = "codex-rmcp-client" version = "0.0.0" dependencies = [ "anyhow", + "axum", + "futures", "mcp-types", "pretty_assertions", + "reqwest", "rmcp", "serde", "serde_json", @@ -2883,6 +2934,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "mcp-types" version = "0.0.0" @@ -3669,7 +3726,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3907,20 +3964,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534fd1cd0601e798ac30545ff2b7f4a62c6f14edd4aaed1cc5eb1e85f69f09af" dependencies = [ "base64", + "bytes", "chrono", "futures", + "http", + "http-body", + "http-body-util", "paste", "pin-project-lite", "process-wrap", + "rand", + "reqwest", "rmcp-macros", "schemars 1.0.4", "serde", "serde_json", + "sse-stream", "thiserror 2.0.16", "tokio", "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -4468,6 +4534,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 465de71aac..0cb448d8be 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeMap; use std::collections::HashMap; use std::path::PathBuf; @@ -13,6 +12,7 @@ use codex_core::config::find_codex_home; use codex_core::config::load_global_mcp_servers; use codex_core::config::write_global_mcp_servers; use codex_core::config_types::McpServerConfig; +use codex_core::config_types::McpServerTransportConfig; /// [experimental] Launch Codex as an MCP server or manage configured MCP servers. /// @@ -145,9 +145,11 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<( .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; let new_entry = McpServerConfig { - command: command_bin, - args: command_args, - env: env_map, + transport: McpServerTransportConfig::Stdio { + command: command_bin, + args: command_args, + env: env_map, + }, startup_timeout_sec: None, tool_timeout_sec: None, }; @@ -201,16 +203,25 @@ fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Resul let json_entries: Vec<_> = entries .into_iter() .map(|(name, cfg)| { - let env = cfg.env.as_ref().map(|env| { - env.iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>() - }); + let transport = match &cfg.transport { + McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ + "type": "stdio", + "command": command, + "args": args, + "env": env, + }), + McpServerTransportConfig::StreamableHttp { url, bearer_token } => { + serde_json::json!({ + "type": "streamable_http", + "url": url, + "bearer_token": bearer_token, + }) + } + }; + serde_json::json!({ "name": name, - "command": cfg.command, - "args": cfg.args, - "env": env, + "transport": transport, "startup_timeout_sec": cfg .startup_timeout_sec .map(|timeout| timeout.as_secs_f64()), @@ -230,62 +241,111 @@ fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Resul return Ok(()); } - let mut rows: Vec<[String; 4]> = Vec::new(); + let mut stdio_rows: Vec<[String; 4]> = Vec::new(); + let mut http_rows: Vec<[String; 3]> = Vec::new(); + for (name, cfg) in entries { - let args = if cfg.args.is_empty() { - "-".to_string() - } else { - cfg.args.join(" ") - }; - - let env = match cfg.env.as_ref() { - None => "-".to_string(), - Some(map) if map.is_empty() => "-".to_string(), - Some(map) => { - let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - pairs - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") + match &cfg.transport { + McpServerTransportConfig::Stdio { command, args, env } => { + let args_display = if args.is_empty() { + "-".to_string() + } else { + args.join(" ") + }; + let env_display = match env.as_ref() { + None => "-".to_string(), + Some(map) if map.is_empty() => "-".to_string(), + Some(map) => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + } + }; + stdio_rows.push([name.clone(), command.clone(), args_display, env_display]); + } + McpServerTransportConfig::StreamableHttp { url, bearer_token } => { + let has_bearer = if bearer_token.is_some() { + "True" + } else { + "False" + }; + http_rows.push([name.clone(), url.clone(), has_bearer.into()]); } - }; - - rows.push([name.clone(), cfg.command.clone(), args, env]); - } - - let mut widths = ["Name".len(), "Command".len(), "Args".len(), "Env".len()]; - for row in &rows { - for (i, cell) in row.iter().enumerate() { - widths[i] = widths[i].max(cell.len()); } } - println!( - "{: Result<( }; if get_args.json { - let env = server.env.as_ref().map(|env| { - env.iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>() - }); + let transport = match &server.transport { + McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ + "type": "stdio", + "command": command, + "args": args, + "env": env, + }), + McpServerTransportConfig::StreamableHttp { url, bearer_token } => serde_json::json!({ + "type": "streamable_http", + "url": url, + "bearer_token": bearer_token, + }), + }; let output = serde_json::to_string_pretty(&serde_json::json!({ "name": get_args.name, - "command": server.command, - "args": server.args, - "env": env, + "transport": transport, "startup_timeout_sec": server .startup_timeout_sec .map(|timeout| timeout.as_secs_f64()), @@ -323,27 +389,38 @@ fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<( } println!("{}", get_args.name); - println!(" command: {}", server.command); - let args = if server.args.is_empty() { - "-".to_string() - } else { - server.args.join(" ") - }; - println!(" args: {args}"); - let env_display = match server.env.as_ref() { - None => "-".to_string(), - Some(map) if map.is_empty() => "-".to_string(), - Some(map) => { - let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - pairs - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") + match &server.transport { + McpServerTransportConfig::Stdio { command, args, env } => { + println!(" transport: stdio"); + println!(" command: {command}"); + let args_display = if args.is_empty() { + "-".to_string() + } else { + args.join(" ") + }; + println!(" args: {args_display}"); + let env_display = match env.as_ref() { + None => "-".to_string(), + Some(map) if map.is_empty() => "-".to_string(), + Some(map) => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + } + }; + println!(" env: {env_display}"); } - }; - println!(" env: {env_display}"); + McpServerTransportConfig::StreamableHttp { url, bearer_token } => { + println!(" transport: streamable_http"); + println!(" url: {url}"); + let bearer = bearer_token.as_deref().unwrap_or("-"); + println!(" bearer_token: {bearer}"); + } + } if let Some(timeout) = server.startup_timeout_sec { println!(" startup_timeout_sec: {}", timeout.as_secs_f64()); } diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 9e54f0d867..cf3ea9f739 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -2,6 +2,7 @@ use std::path::Path; use anyhow::Result; use codex_core::config::load_global_mcp_servers; +use codex_core::config_types::McpServerTransportConfig; use predicates::str::contains; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -26,9 +27,14 @@ fn add_and_remove_server_updates_global_config() -> Result<()> { let servers = load_global_mcp_servers(codex_home.path())?; assert_eq!(servers.len(), 1); let docs = servers.get("docs").expect("server should exist"); - assert_eq!(docs.command, "echo"); - assert_eq!(docs.args, vec!["hello".to_string()]); - assert!(docs.env.is_none()); + match &docs.transport { + McpServerTransportConfig::Stdio { command, args, env } => { + assert_eq!(command, "echo"); + assert_eq!(args, &vec!["hello".to_string()]); + assert!(env.is_none()); + } + other => panic!("unexpected transport: {other:?}"), + } let mut remove_cmd = codex_command(codex_home.path())?; remove_cmd @@ -76,7 +82,10 @@ fn add_with_env_preserves_key_order_and_values() -> Result<()> { let servers = load_global_mcp_servers(codex_home.path())?; let envy = servers.get("envy").expect("server should exist"); - let env = envy.env.as_ref().expect("env should be present"); + let env = match &envy.transport { + McpServerTransportConfig::Stdio { env: Some(env), .. } => env, + other => panic!("unexpected transport: {other:?}"), + }; assert_eq!(env.len(), 2); assert_eq!(env.get("FOO"), Some(&"bar".to_string())); diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index e53f42cc8f..6c83de19fa 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -4,6 +4,7 @@ use anyhow::Result; use predicates::str::contains; use pretty_assertions::assert_eq; use serde_json::Value as JsonValue; +use serde_json::json; use tempfile::TempDir; fn codex_command(codex_home: &Path) -> Result { @@ -58,38 +59,35 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(json_output.status.success()); let stdout = String::from_utf8(json_output.stdout)?; let parsed: JsonValue = serde_json::from_str(&stdout)?; - let array = parsed.as_array().expect("expected array"); - assert_eq!(array.len(), 1); - let entry = &array[0]; - assert_eq!(entry.get("name"), Some(&JsonValue::String("docs".into()))); assert_eq!( - entry.get("command"), - Some(&JsonValue::String("docs-server".into())) - ); - - let args = entry - .get("args") - .and_then(|v| v.as_array()) - .expect("args array"); - assert_eq!( - args, - &vec![ - JsonValue::String("--port".into()), - JsonValue::String("4000".into()) + parsed, + json!([ + { + "name": "docs", + "transport": { + "type": "stdio", + "command": "docs-server", + "args": [ + "--port", + "4000" + ], + "env": { + "TOKEN": "secret" + } + }, + "startup_timeout_sec": null, + "tool_timeout_sec": null + } ] + ) ); - let env = entry - .get("env") - .and_then(|v| v.as_object()) - .expect("env map"); - assert_eq!(env.get("TOKEN"), Some(&JsonValue::String("secret".into()))); - let mut get_cmd = codex_command(codex_home.path())?; let get_output = get_cmd.args(["mcp", "get", "docs"]).output()?; assert!(get_output.status.success()); let stdout = String::from_utf8(get_output.stdout)?; assert!(stdout.contains("docs")); + assert!(stdout.contains("transport: stdio")); assert!(stdout.contains("command: docs-server")); assert!(stdout.contains("args: --port 4000")); assert!(stdout.contains("env: TOKEN=secret")); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 5b5b60f8df..292b9f7b51 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1,6 +1,7 @@ use crate::config_profile::ConfigProfile; use crate::config_types::History; use crate::config_types::McpServerConfig; +use crate::config_types::McpServerTransportConfig; use crate::config_types::Notifications; use crate::config_types::ReasoningSummaryFormat; use crate::config_types::SandboxWorkspaceWrite; @@ -314,27 +315,37 @@ pub fn write_global_mcp_servers( for (name, config) in servers { let mut entry = TomlTable::new(); entry.set_implicit(false); - entry["command"] = toml_edit::value(config.command.clone()); + match &config.transport { + McpServerTransportConfig::Stdio { command, args, env } => { + entry["command"] = toml_edit::value(command.clone()); - if !config.args.is_empty() { - let mut args = TomlArray::new(); - for arg in &config.args { - args.push(arg.clone()); - } - entry["args"] = TomlItem::Value(args.into()); - } + if !args.is_empty() { + let mut args_array = TomlArray::new(); + for arg in args { + args_array.push(arg.clone()); + } + entry["args"] = TomlItem::Value(args_array.into()); + } - if let Some(env) = &config.env - && !env.is_empty() - { - let mut env_table = TomlTable::new(); - env_table.set_implicit(false); - let mut pairs: Vec<_> = env.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - for (key, value) in pairs { - env_table.insert(key, toml_edit::value(value.clone())); + if let Some(env) = env + && !env.is_empty() + { + let mut env_table = TomlTable::new(); + env_table.set_implicit(false); + let mut pairs: Vec<_> = env.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + env_table.insert(key, toml_edit::value(value.clone())); + } + entry["env"] = TomlItem::Table(env_table); + } + } + McpServerTransportConfig::StreamableHttp { url, bearer_token } => { + entry["url"] = toml_edit::value(url.clone()); + if let Some(token) = bearer_token { + entry["bearer_token"] = toml_edit::value(token.clone()); + } } - entry["env"] = TomlItem::Table(env_table); } if let Some(timeout) = config.startup_timeout_sec { @@ -1294,9 +1305,11 @@ exclude_slash_tmp = true servers.insert( "docs".to_string(), McpServerConfig { - command: "echo".to_string(), - args: vec!["hello".to_string()], - env: None, + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string()], + env: None, + }, startup_timeout_sec: Some(Duration::from_secs(3)), tool_timeout_sec: Some(Duration::from_secs(5)), }, @@ -1307,8 +1320,14 @@ exclude_slash_tmp = true let loaded = load_global_mcp_servers(codex_home.path())?; assert_eq!(loaded.len(), 1); let docs = loaded.get("docs").expect("docs entry"); - assert_eq!(docs.command, "echo"); - assert_eq!(docs.args, vec!["hello".to_string()]); + match &docs.transport { + McpServerTransportConfig::Stdio { command, args, env } => { + assert_eq!(command, "echo"); + assert_eq!(args, &vec!["hello".to_string()]); + assert!(env.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3))); assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5))); @@ -1342,6 +1361,134 @@ startup_timeout_ms = 2500 Ok(()) } + #[test] + fn write_global_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: vec!["--verbose".to_string()], + env: Some(HashMap::from([ + ("ZIG_VAR".to_string(), "3".to_string()), + ("ALPHA_VAR".to_string(), "1".to_string()), + ])), + }, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +command = "docs-server" +args = ["--verbose"] + +[mcp_servers.docs.env] +ALPHA_VAR = "1" +ZIG_VAR = "3" +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path())?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { command, args, env } => { + assert_eq!(command, "docs-server"); + assert_eq!(args, &vec!["--verbose".to_string()]); + let env = env + .as_ref() + .expect("env should be preserved for stdio transport"); + assert_eq!(env.get("ALPHA_VAR"), Some(&"1".to_string())); + assert_eq!(env.get("ZIG_VAR"), Some(&"3".to_string())); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[test] + fn write_global_mcp_servers_serializes_streamable_http() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let mut servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token: Some("secret-token".to_string()), + }, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +url = "https://example.com/mcp" +bearer_token = "secret-token" +startup_timeout_sec = 2.0 +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path())?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { url, bearer_token } => { + assert_eq!(url, "https://example.com/mcp"); + assert_eq!(bearer_token.as_deref(), Some("secret-token")); + } + other => panic!("unexpected transport {other:?}"), + } + assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(2))); + + servers.insert( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token: None, + }, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + ); + write_global_mcp_servers(codex_home.path(), &servers)?; + + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +url = "https://example.com/mcp" +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path())?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { url, bearer_token } => { + assert_eq!(url, "https://example.com/mcp"); + assert!(bearer_token.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + #[tokio::test] async fn persist_model_selection_updates_defaults() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index d273b23d69..283ae3a480 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -3,25 +3,20 @@ // Note this file should generally be restricted to simple struct/enum // definitions that do not contain business logic. +use serde::Deserializer; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use wildmatch::WildMatchPattern; use serde::Deserialize; -use serde::Deserializer; use serde::Serialize; use serde::de::Error as SerdeError; #[derive(Serialize, Debug, Clone, PartialEq)] pub struct McpServerConfig { - pub command: String, - - #[serde(default)] - pub args: Vec, - - #[serde(default)] - pub env: Option>, + #[serde(flatten)] + pub transport: McpServerTransportConfig, /// Startup timeout in seconds for initializing MCP server & initially listing tools. #[serde( @@ -43,11 +38,15 @@ impl<'de> Deserialize<'de> for McpServerConfig { { #[derive(Deserialize)] struct RawMcpServerConfig { - command: String, + command: Option, #[serde(default)] - args: Vec, + args: Option>, #[serde(default)] env: Option>, + + url: Option, + bearer_token: Option, + #[serde(default)] startup_timeout_sec: Option, #[serde(default)] @@ -67,16 +66,81 @@ impl<'de> Deserialize<'de> for McpServerConfig { (None, None) => None, }; + fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), E> + where + E: SerdeError, + { + if value.is_none() { + return Ok(()); + } + Err(E::custom(format!( + "{field} is not supported for {transport}", + ))) + } + + let transport = match raw { + RawMcpServerConfig { + command: Some(command), + args, + env, + url, + bearer_token, + .. + } => { + throw_if_set("stdio", "url", url.as_ref())?; + throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?; + McpServerTransportConfig::Stdio { + command, + args: args.unwrap_or_default(), + env, + } + } + RawMcpServerConfig { + url: Some(url), + bearer_token, + command, + args, + env, + .. + } => { + throw_if_set("streamable_http", "command", command.as_ref())?; + throw_if_set("streamable_http", "args", args.as_ref())?; + throw_if_set("streamable_http", "env", env.as_ref())?; + McpServerTransportConfig::StreamableHttp { url, bearer_token } + } + _ => return Err(SerdeError::custom("invalid transport")), + }; + Ok(Self { - command: raw.command, - args: raw.args, - env: raw.env, + transport, startup_timeout_sec, tool_timeout_sec: raw.tool_timeout_sec, }) } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] +pub enum McpServerTransportConfig { + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + env: Option>, + }, + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http + StreamableHttp { + url: String, + /// A plain text bearer token to use for authentication. + /// This bearer token will be included in the HTTP request header as an `Authorization: Bearer ` header. + /// This should be used with caution because it lives on disk in clear text. + #[serde(default, skip_serializing_if = "Option::is_none")] + bearer_token: Option, + }, +} + mod option_duration_secs { use serde::Deserialize; use serde::Deserializer; @@ -303,3 +367,139 @@ pub enum ReasoningSummaryFormat { None, Experimental, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn deserialize_stdio_command_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None + } + ); + } + + #[test] + fn deserialize_stdio_command_server_config_with_args() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: None + } + ); + } + + #[test] + fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + env = { "FOO" = "BAR" } + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])) + } + ); + } + + #[test] + fn deserialize_streamable_http_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token: None + } + ); + } + + #[test] + fn deserialize_streamable_http_server_config_with_bearer_token() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + bearer_token = "secret" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token: Some("secret".to_string()) + } + ); + } + + #[test] + fn deserialize_rejects_command_and_url() { + toml::from_str::( + r#" + command = "echo" + url = "https://example.com" + "#, + ) + .expect_err("should reject command+url"); + } + + #[test] + fn deserialize_rejects_env_for_http_transport() { + toml::from_str::( + r#" + url = "https://example.com" + env = { "FOO" = "BAR" } + "#, + ) + .expect_err("should reject env for http transport"); + } + + #[test] + fn deserialize_rejects_bearer_token_for_stdio_transport() { + toml::from_str::( + r#" + command = "echo" + bearer_token = "secret" + "#, + ) + .expect_err("should reject bearer token for stdio transport"); + } +} diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 5648e20b3b..015ca8c6bb 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -29,6 +29,7 @@ use tracing::info; use tracing::warn; use crate::config_types::McpServerConfig; +use crate::config_types::McpServerTransportConfig; /// Delimiter used to separate the server name from the tool name in a fully /// qualified tool name. @@ -121,6 +122,17 @@ impl McpClientAdapter { } } + async fn new_streamable_http_client( + url: String, + bearer_token: Option, + params: mcp_types::InitializeRequestParams, + startup_timeout: Duration, + ) -> Result { + let client = Arc::new(RmcpClient::new_streamable_http_client(url, bearer_token)?); + client.initialize(params, Some(startup_timeout)).await?; + Ok(McpClientAdapter::Rmcp(client)) + } + async fn list_tools( &self, params: Option, @@ -176,8 +188,6 @@ impl McpConnectionManager { return Ok((Self::default(), ClientStartErrors::default())); } - tracing::error!("new mcp_servers: {mcp_servers:?} use_rmcp_client: {use_rmcp_client}"); - // Launch all configured servers concurrently. let mut join_set = JoinSet::new(); let mut errors = ClientStartErrors::new(); @@ -193,16 +203,24 @@ impl McpConnectionManager { continue; } + if matches!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { .. } + ) && !use_rmcp_client + { + info!( + "skipping MCP server `{}` configured with url because rmcp client is disabled", + server_name + ); + continue; + } + let startup_timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT); let tool_timeout = cfg.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT); let use_rmcp_client_flag = use_rmcp_client; join_set.spawn(async move { - let McpServerConfig { - command, args, env, .. - } = cfg; - let command_os: OsString = command.into(); - let args_os: Vec = args.into_iter().map(Into::into).collect(); + let McpServerConfig { transport, .. } = cfg; let params = mcp_types::InitializeRequestParams { capabilities: ClientCapabilities { experimental: None, @@ -224,15 +242,30 @@ impl McpConnectionManager { protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_owned(), }; - let client = McpClientAdapter::new_stdio_client( - use_rmcp_client_flag, - command_os, - args_os, - env, - params, - startup_timeout, - ) - .await + let client = match transport { + McpServerTransportConfig::Stdio { command, args, env } => { + let command_os: OsString = command.into(); + let args_os: Vec = args.into_iter().map(Into::into).collect(); + McpClientAdapter::new_stdio_client( + use_rmcp_client_flag, + command_os, + args_os, + env, + params.clone(), + startup_timeout, + ) + .await + } + McpServerTransportConfig::StreamableHttp { url, bearer_token } => { + McpClientAdapter::new_streamable_http_client( + url, + bearer_token, + params, + startup_timeout, + ) + .await + } + } .map(|c| (c, startup_timeout)); ((server_name, tool_timeout), client) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2ebe9f011c..aed84ec603 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; +use std::net::TcpListener; use std::time::Duration; use codex_core::config_types::McpServerConfig; +use codex_core::config_types::McpServerTransportConfig; + use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; @@ -16,10 +19,15 @@ use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; use escargot::CargoBuild; use serde_json::Value; +use tokio::net::TcpStream; +use tokio::process::Child; +use tokio::process::Command; +use tokio::time::Instant; +use tokio::time::sleep; use wiremock::matchers::any; -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rmcp_tool_call_round_trip() -> anyhow::Result<()> { +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stdio_server_round_trip() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -54,7 +62,7 @@ async fn rmcp_tool_call_round_trip() -> anyhow::Result<()> { let expected_env_value = "propagated-env"; let rmcp_test_server_bin = CargoBuild::new() .package("codex-rmcp-client") - .bin("rmcp_test_server") + .bin("test_stdio_server") .run()? .path() .to_string_lossy() @@ -66,12 +74,14 @@ async fn rmcp_tool_call_round_trip() -> anyhow::Result<()> { config.mcp_servers.insert( server_name.to_string(), McpServerConfig { - command: rmcp_test_server_bin.clone(), - args: Vec::new(), - env: Some(HashMap::from([( - "MCP_TEST_VALUE".to_string(), - expected_env_value.to_string(), - )])), + transport: McpServerTransportConfig::Stdio { + command: rmcp_test_server_bin.clone(), + args: Vec::new(), + env: Some(HashMap::from([( + "MCP_TEST_VALUE".to_string(), + expected_env_value.to_string(), + )])), + }, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, }, @@ -97,18 +107,13 @@ async fn rmcp_tool_call_round_trip() -> anyhow::Result<()> { }) .await?; - eprintln!("waiting for mcp tool call begin event"); let begin_event = wait_for_event_with_timeout( &fixture.codex, - |ev| { - eprintln!("ev: {ev:?}"); - matches!(ev, EventMsg::McpToolCallBegin(_)) - }, + |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), Duration::from_secs(10), ) .await; - eprintln!("mcp tool call begin event: {begin_event:?}"); let EventMsg::McpToolCallBegin(begin) = begin_event else { unreachable!("event guard guarantees McpToolCallBegin"); }; @@ -119,7 +124,6 @@ async fn rmcp_tool_call_round_trip() -> anyhow::Result<()> { matches!(ev, EventMsg::McpToolCallEnd(_)) }) .await; - eprintln!("end_event: {end_event:?}"); let EventMsg::McpToolCallEnd(end) = end_event else { unreachable!("event guard guarantees McpToolCallEnd"); }; @@ -145,18 +149,223 @@ async fn rmcp_tool_call_round_trip() -> anyhow::Result<()> { .get("echo") .and_then(Value::as_str) .expect("echo payload present"); - assert_eq!(echo_value, "ping"); + assert_eq!(echo_value, "ECHOING: ping"); let env_value = map .get("env") .and_then(Value::as_str) .expect("env snapshot inserted"); assert_eq!(env_value, expected_env_value); - let task_complete_event = - wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - eprintln!("task_complete_event: {task_complete_event:?}"); + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; server.verify().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + + let call_id = "call-456"; + let server_name = "rmcp_http"; + let tool_name = format!("{server_name}__echo"); + + mount_sse_once( + &server, + any(), + responses::sse(vec![ + serde_json::json!({ + "type": "response.created", + "response": {"id": "resp-1"} + }), + responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + mount_sse_once( + &server, + any(), + responses::sse(vec![ + responses::ev_assistant_message( + "msg-1", + "rmcp streamable http echo tool completed successfully.", + ), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let expected_env_value = "propagated-env-http"; + let rmcp_http_server_bin = CargoBuild::new() + .package("codex-rmcp-client") + .bin("test_streamable_http_server") + .run()? + .path() + .to_string_lossy() + .into_owned(); + + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + drop(listener); + let bind_addr = format!("127.0.0.1:{port}"); + let server_url = format!("http://{bind_addr}/mcp"); + + let mut http_server_child = Command::new(&rmcp_http_server_bin) + .kill_on_drop(true) + .env("MCP_STREAMABLE_HTTP_BIND_ADDR", &bind_addr) + .env("MCP_TEST_VALUE", expected_env_value) + .spawn()?; + + wait_for_streamable_http_server(&mut http_server_child, &bind_addr, Duration::from_secs(5)) + .await?; + + let fixture = test_codex() + .with_config(move |config| { + config.use_experimental_use_rmcp_client = true; + config.mcp_servers.insert( + server_name.to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: server_url, + bearer_token: None, + }, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + }, + ); + }) + .build(&server) + .await?; + let session_model = fixture.session_configured.model.clone(); + + fixture + .codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "call the rmcp streamable http echo tool".into(), + }], + final_output_json_schema: None, + cwd: fixture.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_with_timeout( + &fixture.codex, + |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), + Duration::from_secs(10), + ) + .await; + + let EventMsg::McpToolCallBegin(begin) = begin_event else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(begin.invocation.server, server_name); + assert_eq!(begin.invocation.tool, "echo"); + + let end_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallEnd(_)) + }) + .await; + let EventMsg::McpToolCallEnd(end) = end_event else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + + let result = end + .result + .as_ref() + .expect("rmcp echo tool should return success"); + assert_eq!(result.is_error, Some(false)); + assert!( + result.content.is_empty(), + "content should default to an empty array" + ); + + let structured = result + .structured_content + .as_ref() + .expect("structured content"); + let Value::Object(map) = structured else { + panic!("structured content should be an object: {structured:?}"); + }; + let echo_value = map + .get("echo") + .and_then(Value::as_str) + .expect("echo payload present"); + assert_eq!(echo_value, "ECHOING: ping"); + let env_value = map + .get("env") + .and_then(Value::as_str) + .expect("env snapshot inserted"); + assert_eq!(env_value, expected_env_value); + + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + server.verify().await; + + match http_server_child.try_wait() { + Ok(Some(_)) => {} + Ok(None) => { + let _ = http_server_child.kill().await; + } + Err(error) => { + eprintln!("failed to check streamable http server status: {error}"); + let _ = http_server_child.kill().await; + } + } + if let Err(error) = http_server_child.wait().await { + eprintln!("failed to await streamable http server shutdown: {error}"); + } + + Ok(()) +} + +async fn wait_for_streamable_http_server( + server_child: &mut Child, + address: &str, + timeout: Duration, +) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + + loop { + if let Some(status) = server_child.try_wait()? { + return Err(anyhow::anyhow!( + "streamable HTTP server exited early with status {status}" + )); + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + + if remaining.is_zero() { + return Err(anyhow::anyhow!( + "timed out waiting for streamable HTTP server at {address}: deadline reached" + )); + } + + match tokio::time::timeout(remaining, TcpStream::connect(address)).await { + Ok(Ok(_)) => return Ok(()), + Ok(Err(error)) => { + if Instant::now() >= deadline { + return Err(anyhow::anyhow!( + "timed out waiting for streamable HTTP server at {address}: {error}" + )); + } + } + Err(_) => { + return Err(anyhow::anyhow!( + "timed out waiting for streamable HTTP server at {address}: connect call timed out" + )); + } + } + + sleep(Duration::from_millis(50)).await; + } +} diff --git a/codex-rs/justfile b/codex-rs/justfile index 850737efd6..9ddc4a37aa 100644 --- a/codex-rs/justfile +++ b/codex-rs/justfile @@ -5,6 +5,7 @@ help: just -l # `codex` +alias c := codex codex *args: cargo run --bin codex -- "$@" @@ -27,6 +28,9 @@ fmt: fix *args: cargo clippy --fix --all-features --tests --allow-dirty "$@" +clippy: + cargo clippy --all-features --tests "$@" + install: rustup show active-toolchain cargo fetch diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index da9989e531..a377b94f07 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -16,6 +16,15 @@ rmcp = { version = "0.7.0", default-features = false, features = [ "schemars", "server", "transport-child-process", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] } +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +futures = { version = "0.3", default-features = false, features = ["std"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "stream", + "rustls-tls", ] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs new file mode 100644 index 0000000000..2d380fa54e --- /dev/null +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -0,0 +1,142 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::Arc; + +use rmcp::ErrorData as McpError; +use rmcp::ServiceExt; +use rmcp::handler::server::ServerHandler; +use rmcp::model::CallToolRequestParam; +use rmcp::model::CallToolResult; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::PaginatedRequestParam; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use serde::Deserialize; +use serde_json::json; +use tokio::task; + +#[derive(Clone)] +struct TestToolServer { + tools: Arc>, +} +pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { + (tokio::io::stdin(), tokio::io::stdout()) +} +impl TestToolServer { + fn new() -> Self { + let tools = vec![Self::echo_tool()]; + Self { + tools: Arc::new(tools), + } + } + + fn echo_tool() -> Tool { + #[expect(clippy::expect_used)] + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "message": { "type": "string" }, + "env_var": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": false + })) + .expect("echo tool schema should deserialize"); + + Tool::new( + Cow::Borrowed("echo"), + Cow::Borrowed("Echo back the provided message and include environment data."), + Arc::new(schema), + ) + } +} + +#[derive(Deserialize)] +struct EchoArgs { + message: String, + #[allow(dead_code)] + env_var: Option, +} + +impl ServerHandler for TestToolServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ..ServerInfo::default() + } + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let tools = self.tools.clone(); + async move { + Ok(ListToolsResult { + tools: (*tools).clone(), + next_cursor: None, + }) + } + } + + async fn call_tool( + &self, + request: CallToolRequestParam, + _context: rmcp::service::RequestContext, + ) -> Result { + match request.name.as_ref() { + "echo" => { + let args: EchoArgs = match request.arguments { + Some(arguments) => serde_json::from_value(serde_json::Value::Object( + arguments.into_iter().collect(), + )) + .map_err(|err| McpError::invalid_params(err.to_string(), None))?, + None => { + return Err(McpError::invalid_params( + "missing arguments for echo tool", + None, + )); + } + }; + + let env_snapshot: HashMap = std::env::vars().collect(); + let structured_content = json!({ + "echo": format!("ECHOING: {}", args.message), + "env": env_snapshot.get("MCP_TEST_VALUE"), + }); + + Ok(CallToolResult { + content: Vec::new(), + structured_content: Some(structured_content), + is_error: Some(false), + meta: None, + }) + } + other => Err(McpError::invalid_params( + format!("unknown tool: {other}"), + None, + )), + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + eprintln!("starting rmcp test server"); + // Run the server with STDIO transport. If the client disconnects we simply + // bubble up the error so the process exits. + let service = TestToolServer::new(); + let running = service.serve(stdio()).await?; + + // Wait for the client to finish interacting with the server. + running.waiting().await?; + // Drain background tasks to ensure clean shutdown. + task::yield_now().await; + Ok(()) +} diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs new file mode 100644 index 0000000000..eedd1cb1e3 --- /dev/null +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -0,0 +1,167 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::io::ErrorKind; +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::Router; +use rmcp::ErrorData as McpError; +use rmcp::handler::server::ServerHandler; +use rmcp::model::CallToolRequestParam; +use rmcp::model::CallToolResult; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::PaginatedRequestParam; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde::Deserialize; +use serde_json::json; +use tokio::task; + +#[derive(Clone)] +struct TestToolServer { + tools: Arc>, +} + +impl TestToolServer { + fn new() -> Self { + let tools = vec![Self::echo_tool()]; + Self { + tools: Arc::new(tools), + } + } + + fn echo_tool() -> Tool { + #[expect(clippy::expect_used)] + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "message": { "type": "string" }, + "env_var": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": false + })) + .expect("echo tool schema should deserialize"); + + Tool::new( + Cow::Borrowed("echo"), + Cow::Borrowed("Echo back the provided message and include environment data."), + Arc::new(schema), + ) + } +} + +#[derive(Deserialize)] +struct EchoArgs { + message: String, + #[allow(dead_code)] + env_var: Option, +} + +impl ServerHandler for TestToolServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ..ServerInfo::default() + } + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let tools = self.tools.clone(); + async move { + Ok(ListToolsResult { + tools: (*tools).clone(), + next_cursor: None, + }) + } + } + + async fn call_tool( + &self, + request: CallToolRequestParam, + _context: rmcp::service::RequestContext, + ) -> Result { + match request.name.as_ref() { + "echo" => { + let args: EchoArgs = match request.arguments { + Some(arguments) => serde_json::from_value(serde_json::Value::Object( + arguments.into_iter().collect(), + )) + .map_err(|err| McpError::invalid_params(err.to_string(), None))?, + None => { + return Err(McpError::invalid_params( + "missing arguments for echo tool", + None, + )); + } + }; + + let env_snapshot: HashMap = std::env::vars().collect(); + let structured_content = json!({ + "echo": format!("ECHOING: {}", args.message), + "env": env_snapshot.get("MCP_TEST_VALUE"), + }); + + Ok(CallToolResult { + content: Vec::new(), + structured_content: Some(structured_content), + is_error: Some(false), + meta: None, + }) + } + other => Err(McpError::invalid_params( + format!("unknown tool: {other}"), + None, + )), + } + } +} + +fn parse_bind_addr() -> Result> { + let default_addr = "127.0.0.1:3920"; + let bind_addr = std::env::var("MCP_STREAMABLE_HTTP_BIND_ADDR") + .or_else(|_| std::env::var("BIND_ADDR")) + .unwrap_or_else(|_| default_addr.to_string()); + Ok(bind_addr.parse()?) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let bind_addr = parse_bind_addr()?; + let listener = match tokio::net::TcpListener::bind(&bind_addr).await { + Ok(listener) => listener, + Err(err) if err.kind() == ErrorKind::PermissionDenied => { + eprintln!( + "failed to bind to {bind_addr}: {err}. make sure the process has network access" + ); + return Ok(()); + } + Err(err) => return Err(err.into()), + }; + eprintln!("starting rmcp streamable http test server on http://{bind_addr}/mcp"); + + let router = Router::new().nest_service( + "/mcp", + StreamableHttpService::new( + || Ok(TestToolServer::new()), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ), + ); + + axum::serve(listener, router).await?; + task::yield_now().await; + Ok(()) +} diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index c7ac1ecc9a..e127029472 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::Result; use anyhow::anyhow; +use futures::FutureExt; use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; use mcp_types::InitializeRequestParams; @@ -19,7 +20,9 @@ use rmcp::model::PaginatedRequestParam; use rmcp::service::RoleClient; use rmcp::service::RunningService; use rmcp::service::{self}; +use rmcp::transport::StreamableHttpClientTransport; use rmcp::transport::child_process::TokioChildProcess; +use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Command; @@ -35,9 +38,14 @@ use crate::utils::convert_to_rmcp; use crate::utils::create_env_for_mcp_server; use crate::utils::run_with_timeout; +enum PendingTransport { + ChildProcess(TokioChildProcess), + StreamableHttp(StreamableHttpClientTransport), +} + enum ClientState { Connecting { - transport: Option, + transport: Option, }, Ready { service: Arc>, @@ -90,7 +98,22 @@ impl RmcpClient { Ok(Self { state: Mutex::new(ClientState::Connecting { - transport: Some(transport), + transport: Some(PendingTransport::ChildProcess(transport)), + }), + }) + } + + pub fn new_streamable_http_client(url: String, bearer_token: Option) -> Result { + let mut config = StreamableHttpClientTransportConfig::with_uri(url); + if let Some(token) = bearer_token { + config = config.auth_header(format!("Bearer {token}")); + } + + let transport = StreamableHttpClientTransport::from_config(config); + + Ok(Self { + state: Mutex::new(ClientState::Connecting { + transport: Some(PendingTransport::StreamableHttp(transport)), }), }) } @@ -116,7 +139,14 @@ impl RmcpClient { let client_info = convert_to_rmcp::<_, InitializeRequestParam>(params.clone())?; let client_handler = LoggingClientHandler::new(client_info); - let service_future = service::serve_client(client_handler, transport); + let service_future = match transport { + PendingTransport::ChildProcess(transport) => { + service::serve_client(client_handler.clone(), transport).boxed() + } + PendingTransport::StreamableHttp(transport) => { + service::serve_client(client_handler, transport).boxed() + } + }; let service = match timeout { Some(duration) => time::timeout(duration, service_future) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 941a7fbdda..2951ca5b78 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -18,6 +18,7 @@ use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use base64::Engine; use codex_core::config::Config; +use codex_core::config_types::McpServerTransportConfig; use codex_core::config_types::ReasoningSummaryFormat; use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; @@ -868,10 +869,19 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Server: ".into(), server.clone().into()].into()); - if !cfg.command.is_empty() { - let cmd_display = format!("{} {}", cfg.command, cfg.args.join(" ")); - - lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); + match &cfg.transport { + McpServerTransportConfig::Stdio { command, args, .. } => { + let args_suffix = if args.is_empty() { + String::new() + } else { + format!(" {}", args.join(" ")) + }; + let cmd_display = format!("{command}{args_suffix}"); + lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); + } + McpServerTransportConfig::StreamableHttp { url, .. } => { + lines.push(vec![" • URL: ".into(), url.clone().into()].into()); + } } if names.is_empty() { From 2719fdd12ab2eb325e62828e67866d8a1b78a3a9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 26 Sep 2025 18:24:26 -0700 Subject: [PATCH 15/31] Add "? for shortcuts" (#4316) https://github.com/user-attachments/assets/9e61b197-024b-4cbc-b40d-c446b448e759 --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 239 +++++++-- codex-rs/tui/src/bottom_pane/footer.rs | 475 ++++++++++-------- codex-rs/tui/src/bottom_pane/mod.rs | 8 - ...mposer__tests__backspace_after_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__empty.snap | 2 +- ...__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 + ...r_mode_overlay_then_external_esc_hint.snap | 13 + ...__tests__footer_mode_shortcut_overlay.snap | 15 + ...tom_pane__chat_composer__tests__large.snap | 2 +- ...chat_composer__tests__multiple_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__small.snap | 2 +- ...ooter__tests__footer_ctrl_c_quit_idle.snap | 4 +- ...er__tests__footer_ctrl_c_quit_running.snap | 4 +- ...__footer__tests__footer_esc_hint_idle.snap | 5 + ...footer__tests__footer_esc_hint_primed.snap | 5 + ...oter__tests__footer_shortcuts_default.snap | 4 +- ...tests__footer_shortcuts_shift_and_esc.snap | 6 +- codex-rs/tui/src/chatwidget.rs | 2 - ...exec_and_status_layout_vt100_snapshot.snap | 3 +- ...atwidget__tests__status_widget_active.snap | 3 +- codex-rs/tui/src/key_hint.rs | 26 - 25 files changed, 594 insertions(+), 293 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b3c1eea6d9..3b02c59334 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,4 +1,3 @@ -use codex_core::protocol::TokenUsageInfo; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -19,8 +18,14 @@ 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::prompt_mode; 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 crate::bottom_pane::paste_burst::FlushResult; @@ -74,7 +79,6 @@ pub(crate) struct ChatComposer { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, - token_usage_info: Option, has_focus: bool, attached_images: Vec, placeholder_text: String, @@ -84,6 +88,7 @@ pub(crate) struct ChatComposer { // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, custom_prompts: Vec, + footer_mode: FooterMode, } /// Popup state – at most one can be visible at any time. @@ -93,7 +98,7 @@ enum ActivePopup { File(FileSearchPopup), } -const FOOTER_HINT_HEIGHT: u16 = 1; +const FOOTER_SPACING_HEIGHT: u16 = 1; impl ChatComposer { pub fn new( @@ -117,7 +122,6 @@ impl ChatComposer { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), - token_usage_info: None, has_focus: has_input_focus, attached_images: Vec::new(), placeholder_text, @@ -125,6 +129,7 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), + footer_mode: FooterMode::ShortcutPrompt, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -132,26 +137,41 @@ impl ChatComposer { } pub fn desired_height(&self, width: u16) -> u16 { + let footer_props = self.footer_props(); + let footer_hint_height = footer_height(footer_props); + let footer_spacing = if footer_hint_height > 0 { + FOOTER_SPACING_HEIGHT + } else { + 0 + }; + let footer_total_height = footer_hint_height + footer_spacing; self.textarea .desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) + 2 + match &self.active_popup { - ActivePopup::None => FOOTER_HINT_HEIGHT, + ActivePopup::None => footer_total_height, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), } } fn layout_areas(&self, area: Rect) -> [Rect; 3] { + let footer_props = self.footer_props(); + let footer_hint_height = footer_height(footer_props); + let footer_spacing = if footer_hint_height > 0 { + FOOTER_SPACING_HEIGHT + } else { + 0 + }; + 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::None => Constraint::Max(FOOTER_HINT_HEIGHT), + ActivePopup::None => Constraint::Max(footer_total_height), }; let mut area = area; - // Leave an empty row at the top, unless there isn't room. if area.height > 1 { area.height -= 1; area.y += 1; @@ -175,13 +195,6 @@ impl ChatComposer { self.textarea.is_empty() } - /// Update the cached *context-left* percentage and refresh the placeholder - /// text. The UI relies on the placeholder to convey the remaining - /// context when the composer is empty. - pub(crate) fn set_token_usage(&mut self, token_info: Option) { - self.token_usage_info = token_info; - } - /// 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) { @@ -319,6 +332,11 @@ impl ChatComposer { 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 = prompt_mode(); + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } self.set_has_focus(has_focus); } @@ -354,6 +372,18 @@ impl ChatComposer { /// 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!(); }; @@ -478,6 +508,18 @@ impl ChatComposer { /// 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!(); }; @@ -729,6 +771,18 @@ impl ChatComposer { /// 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 { + 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'), @@ -872,6 +926,10 @@ impl ChatComposer { 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() @@ -1145,6 +1203,39 @@ impl ChatComposer { false } + fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { + if key_event.kind == KeyEventKind::Press + && key_event.modifiers.is_empty() + && matches!(key_event.code, KeyCode::Char('?')) + { + let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); + let changed = next != self.footer_mode; + self.footer_mode = next; + changed + } else { + false + } + } + + 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, + } + } + + fn footer_mode(&self) -> FooterMode { + if matches!(self.footer_mode, FooterMode::EscHint) { + FooterMode::EscHint + } else if self.ctrl_c_quit_hint { + FooterMode::CtrlCReminder + } else { + self.footer_mode + } + } + /// 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. @@ -1228,10 +1319,18 @@ impl ChatComposer { pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; + if running { + self.footer_mode = prompt_mode(); + } } 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); + } } } @@ -1252,20 +1351,26 @@ impl WidgetRef for ChatComposer { popup.render_ref(popup_rect, buf); } ActivePopup::None => { - let mut hint_rect = popup_rect; - hint_rect.x += 2; - hint_rect.width = hint_rect.width.saturating_sub(2); - render_footer( - hint_rect, - buf, - FooterProps { - ctrl_c_quit_hint: self.ctrl_c_quit_hint, - is_task_running: self.is_task_running, - esc_backtrack_hint: self.esc_backtrack_hint, - use_shift_enter_hint: self.use_shift_enter_hint, - token_usage_info: self.token_usage_info.as_ref(), - }, - ); + let footer_hint_height = footer_height(self.footer_props()); + let footer_spacing = if footer_hint_height > 0 { + FOOTER_SPACING_HEIGHT + } else { + 0 + }; + 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 + }; + let mut footer_rect = hint_rect; + footer_rect.x = footer_rect.x.saturating_add(2); + footer_rect.width = footer_rect.width.saturating_sub(2); + render_footer(footer_rect, buf, self.footer_props()); } } let style = user_message_style(terminal_palette::default_bg()); @@ -1304,6 +1409,7 @@ mod tests { 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::footer::footer_height; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -1334,7 +1440,7 @@ mod tests { let mut hint_row: Option<(u16, String)> = None; for y in 0..area.height { let row = row_to_string(y); - if row.contains(" send") { + if row.contains("? for shortcuts") { hint_row = Some((y, row)); break; } @@ -1361,6 +1467,81 @@ mod tests { ); } + 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_lines = footer_height(composer.footer_props()); + let height = footer_lines + 8; + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|f| f.render_widget_ref(composer, f.area())) + .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); + }, + ); + } + #[test] fn test_current_at_token_basic_cases() { let test_cases = vec![ diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 77e2c106e5..2e63493e31 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,149 +1,190 @@ -use codex_core::protocol::TokenUsageInfo; -use codex_protocol::num_format::format_si_suffix; use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; 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; use ratatui::text::Span; use ratatui::widgets::WidgetRef; -use crate::key_hint; - #[derive(Clone, Copy, Debug)] -pub(crate) struct FooterProps<'a> { - pub(crate) ctrl_c_quit_hint: bool, - pub(crate) is_task_running: bool, +pub(crate) struct FooterProps { + pub(crate) mode: FooterMode, pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, - pub(crate) token_usage_info: Option<&'a TokenUsageInfo>, + pub(crate) is_task_running: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FooterMode { + CtrlCReminder, + ShortcutPrompt, + ShortcutOverlay, + EscHint, +} + +pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { + if ctrl_c_hint { + return current; + } + match current { + FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt, + _ => 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::ShortcutPrompt, + other => other, + } +} + +pub(crate) fn prompt_mode() -> FooterMode { + FooterMode::ShortcutPrompt +} + +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) { + let lines = footer_lines(props); + for (idx, line) in lines.into_iter().enumerate() { + let y = area.y + idx as u16; + if y >= area.y + area.height { + break; + } + let row = Rect::new(area.x, y, area.width, 1); + line.render_ref(row, buf); + } +} + +fn footer_lines(props: FooterProps) -> Vec> { + match props.mode { + FooterMode::CtrlCReminder => { + vec![ctrl_c_reminder_line(CtrlCReminderState { + is_task_running: props.is_task_running, + })] + } + FooterMode::ShortcutPrompt => vec![Line::from(vec!["? for shortcuts".dim()])], + FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + is_task_running: props.is_task_running, + }), + FooterMode::EscHint => { + vec![esc_hint_line(ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + is_task_running: props.is_task_running, + })] + } + } } #[derive(Clone, Copy, Debug)] struct CtrlCReminderState { - pub(crate) is_task_running: bool, + is_task_running: bool, } #[derive(Clone, Copy, Debug)] struct ShortcutsState { - pub(crate) use_shift_enter_hint: bool, - pub(crate) esc_backtrack_hint: bool, + use_shift_enter_hint: bool, + esc_backtrack_hint: bool, + is_task_running: bool, } -#[derive(Clone, Copy, Debug)] -enum FooterContent { - Shortcuts(ShortcutsState), - CtrlCReminder(CtrlCReminderState), -} - -pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) { - let content = if props.ctrl_c_quit_hint { - FooterContent::CtrlCReminder(CtrlCReminderState { - is_task_running: props.is_task_running, - }) +fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { + let action = if state.is_task_running { + "interrupt" } else { - FooterContent::Shortcuts(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - }) + "quit" }; - - let mut spans = footer_spans(content); - if let Some(token_usage_info) = props.token_usage_info { - append_token_usage_spans(&mut spans, token_usage_info); - } - - let spans = spans - .into_iter() - .map(|span| span.patch_style(Style::default().dim())) - .collect::>(); - Line::from(spans).render_ref(area, buf); + Line::from(vec![ + Span::from(format!(" ctrl + c again to {action}")).dim(), + ]) } -fn footer_spans(content: FooterContent) -> Vec> { - match content { - FooterContent::Shortcuts(state) => shortcuts_spans(state), - FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state), - } +fn esc_hint_line(state: ShortcutsState) -> Line<'static> { + let text = if state.esc_backtrack_hint { + " esc again to edit previous message" + } else { + " esc esc to edit previous message" + }; + Line::from(vec![Span::from(text).dim()]) } -fn append_token_usage_spans(spans: &mut Vec>, token_usage_info: &TokenUsageInfo) { - let token_usage = &token_usage_info.total_token_usage; - spans.push(" ".into()); - spans.push( - Span::from(format!( - "{} tokens used", - format_si_suffix(token_usage.blended_total()) - )) - .style(Style::default().add_modifier(Modifier::DIM)), - ); - - let last_token_usage = &token_usage_info.last_token_usage; - if let Some(context_window) = token_usage_info.model_context_window { - let percent_remaining: u8 = if context_window > 0 { - last_token_usage.percent_of_context_window_remaining(context_window) - } else { - 100 - }; - - let context_style = if percent_remaining < 20 { - Style::default().fg(Color::Yellow) - } else { - Style::default().add_modifier(Modifier::DIM) - }; - spans.push(" ".into()); - spans.push(Span::styled( - format!("{percent_remaining}% context left"), - context_style, - )); - } -} - -fn shortcuts_spans(state: ShortcutsState) -> Vec> { - let mut spans = Vec::new(); +fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { + let mut rendered = Vec::new(); for descriptor in SHORTCUTS { - if let Some(segment) = descriptor.footer_segment(state) { - if !segment.prefix.is_empty() { - spans.push(segment.prefix.into()); - } - spans.push(segment.binding.span()); - spans.push(segment.label.into()); + if let Some(text) = descriptor.overlay_entry(state) { + rendered.push(text); } } - spans + build_columns(rendered) } -fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec> { - let followup = if state.is_task_running { - " to interrupt" - } else { - " to quit" - }; - vec![ - " ".into(), - key_hint::ctrl('C'), - " again".into(), - followup.into(), - ] +fn build_columns(entries: Vec) -> Vec> { + if entries.is_empty() { + return Vec::new(); + } + + const COLUMNS: usize = 3; + const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28]; + + let rows = entries.len().div_ceil(COLUMNS); + 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.len()); + } + + let mut lines = Vec::new(); + for row in 0..rows { + let mut line = String::from(" "); + for col in 0..COLUMNS { + let idx = row * COLUMNS + col; + if idx >= entries.len() { + continue; + } + let entry = &entries[idx]; + if col < COLUMNS - 1 { + let max_width = MAX_PADDED_WIDTHS[col]; + let target_width = column_widths[col].min(max_width); + let pad_width = target_width + 2; + line.push_str(&format!("{entry: Span<'static> { - self.display.into_span() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShortcutDisplay { - Plain(&'static str), - Ctrl(char), - Shift(char), -} - -impl ShortcutDisplay { - fn into_span(self) -> Span<'static> { - match self { - ShortcutDisplay::Plain(text) => key_hint::plain(text), - ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch), - ShortcutDisplay::Shift(ch) => key_hint::shift(ch), - } + fn matches(&self, state: ShortcutsState) -> bool { + self.condition.matches(state) } } @@ -198,49 +222,66 @@ impl DisplayCondition { struct ShortcutDescriptor { id: ShortcutId, bindings: &'static [ShortcutBinding], - footer_label: &'static str, - footer_prefix: &'static str, + prefix: &'static str, + label: &'static str, } impl ShortcutDescriptor { - fn binding_for(&self, state: ShortcutsState) -> Option { - self.bindings - .iter() - .find(|binding| binding.condition.matches(state)) - .copied() + fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { + self.bindings.iter().find(|binding| binding.matches(state)) } - fn should_show(&self, state: ShortcutsState) -> bool { - match self.id { - ShortcutId::EditPrevious => state.esc_backtrack_hint, - _ => true, - } - } - - fn footer_segment(&self, state: ShortcutsState) -> Option { - if !self.should_show(state) { - return None; - } + fn overlay_entry(&self, state: ShortcutsState) -> Option { let binding = self.binding_for(state)?; - Some(FooterSegment { - prefix: self.footer_prefix, - binding, - label: self.footer_label, - }) + let label = match self.id { + ShortcutId::Quit => { + if state.is_task_running { + " to interrupt" + } else { + self.label + } + } + ShortcutId::EditPrevious => { + if state.esc_backtrack_hint { + " again to edit previous message" + } else { + " esc to edit previous message" + } + } + _ => self.label, + }; + let text = match self.id { + ShortcutId::Quit if state.is_task_running => { + format!("{}{} to interrupt", self.prefix, binding.overlay_text) + } + _ => format!("{}{}{}", self.prefix, binding.overlay_text, label), + }; + Some(text) } } const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { - id: ShortcutId::Send, + id: ShortcutId::Commands, bindings: &[ShortcutBinding { - code: KeyCode::Enter, + code: KeyCode::Char('/'), modifiers: KeyModifiers::NONE, - display: ShortcutDisplay::Plain("⏎"), + overlay_text: "/", condition: DisplayCondition::Always, }], - footer_label: " send ", - footer_prefix: "", + prefix: "", + label: " for commands", + }, + ShortcutDescriptor { + id: ShortcutId::FilePaths, + bindings: &[ShortcutBinding { + code: KeyCode::Char('@'), + modifiers: KeyModifiers::NONE, + overlay_text: "@", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for file paths", }, ShortcutDescriptor { id: ShortcutId::InsertNewline, @@ -248,138 +289,154 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutBinding { code: KeyCode::Enter, modifiers: KeyModifiers::SHIFT, - display: ShortcutDisplay::Shift('⏎'), + overlay_text: "shift + enter", condition: DisplayCondition::WhenShiftEnterHint, }, ShortcutBinding { code: KeyCode::Char('j'), modifiers: KeyModifiers::CONTROL, - display: ShortcutDisplay::Ctrl('J'), + overlay_text: "ctrl + j", condition: DisplayCondition::WhenNotShiftEnterHint, }, ], - footer_label: " newline ", - footer_prefix: "", + prefix: "", + label: " for newline", }, ShortcutDescriptor { - id: ShortcutId::ShowTranscript, + id: ShortcutId::PasteImage, bindings: &[ShortcutBinding { - code: KeyCode::Char('t'), + code: KeyCode::Char('v'), modifiers: KeyModifiers::CONTROL, - display: ShortcutDisplay::Ctrl('T'), + overlay_text: "ctrl + v", condition: DisplayCondition::Always, }], - footer_label: " transcript ", - footer_prefix: "", + prefix: "", + label: " to paste images", }, ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, - display: ShortcutDisplay::Ctrl('C'), + overlay_text: "ctrl + c", condition: DisplayCondition::Always, }], - footer_label: " quit", - footer_prefix: "", + prefix: "", + label: " to exit", + }, + ShortcutDescriptor { + id: ShortcutId::ShowTranscript, + bindings: &[ShortcutBinding { + code: KeyCode::Char('t'), + modifiers: KeyModifiers::CONTROL, + overlay_text: "ctrl + t", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to view transcript", + }, + ShortcutDescriptor { + id: ShortcutId::ToggleOverlay, + bindings: &[ShortcutBinding { + code: KeyCode::Char('?'), + modifiers: KeyModifiers::NONE, + overlay_text: "?", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to hide shortcuts", }, ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { code: KeyCode::Esc, modifiers: KeyModifiers::NONE, - display: ShortcutDisplay::Plain("Esc"), + overlay_text: "esc", condition: DisplayCondition::Always, }], - footer_label: " edit prev", - footer_prefix: " ", + prefix: "", + label: "", }, ]; #[cfg(test)] mod tests { use super::*; - use codex_core::protocol::TokenUsage; use insta::assert_snapshot; use ratatui::Terminal; use ratatui::backend::TestBackend; - fn snapshot_footer(name: &str, props: FooterProps<'_>) { - let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap(); + 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, 1); + let area = Rect::new(0, 0, f.area().width, height); render_footer(area, f.buffer_mut(), props); }) .unwrap(); assert_snapshot!(name, terminal.backend()); } - fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo { - let usage = TokenUsage { - input_tokens: total_tokens, - cached_input_tokens: 0, - output_tokens: 0, - reasoning_output_tokens: 0, - total_tokens, - }; - let last = TokenUsage { - input_tokens: last_tokens, - cached_input_tokens: 0, - output_tokens: 0, - reasoning_output_tokens: 0, - total_tokens: last_tokens, - }; - TokenUsageInfo { - total_token_usage: usage, - last_token_usage: last, - model_context_window: Some(context_window), - } - } - #[test] fn footer_snapshots() { snapshot_footer( "footer_shortcuts_default", FooterProps { - ctrl_c_quit_hint: false, - is_task_running: false, + mode: FooterMode::ShortcutPrompt, esc_backtrack_hint: false, use_shift_enter_hint: false, - token_usage_info: None, + is_task_running: false, }, ); snapshot_footer( "footer_shortcuts_shift_and_esc", FooterProps { - ctrl_c_quit_hint: false, - is_task_running: false, + mode: FooterMode::ShortcutOverlay, esc_backtrack_hint: true, use_shift_enter_hint: true, - token_usage_info: Some(&token_usage(4_200, 900, 8_000)), + is_task_running: false, }, ); snapshot_footer( "footer_ctrl_c_quit_idle", FooterProps { - ctrl_c_quit_hint: true, - is_task_running: false, + mode: FooterMode::CtrlCReminder, esc_backtrack_hint: false, use_shift_enter_hint: false, - token_usage_info: None, + is_task_running: false, }, ); snapshot_footer( "footer_ctrl_c_quit_running", FooterProps { - ctrl_c_quit_hint: true, - is_task_running: true, + mode: FooterMode::CtrlCReminder, esc_backtrack_hint: false, use_shift_enter_hint: false, - token_usage_info: None, + is_task_running: true, + }, + ); + + snapshot_footer( + "footer_esc_hint_idle", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + }, + ); + + snapshot_footer( + "footer_esc_hint_primed", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: true, + use_shift_enter_hint: false, + is_task_running: false, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 42af749bb7..b6c93d2813 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; -use codex_core::protocol::TokenUsageInfo; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -373,13 +372,6 @@ impl BottomPane { !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() } - /// Update the *context-window remaining* indicator in the composer. This - /// is forwarded directly to the underlying `ChatComposer`. - pub(crate) fn set_token_usage(&mut self, token_info: Option) { - self.composer.set_token_usage(token_info); - self.request_redraw(); - } - pub(crate) fn show_view(&mut self, view: Box) { self.push_view(view); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index 3f57cab0f6..3c84d708b0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index beccbaf7c7..3bdd7260ff 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap new file mode 100644 index 0000000000..6f8ad7b98c --- /dev/null +++ b/codex-rs/tui/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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap new file mode 100644 index 0000000000..f7917277fd --- /dev/null +++ b/codex-rs/tui/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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap new file mode 100644 index 0000000000..99d263da31 --- /dev/null +++ b/codex-rs/tui/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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap new file mode 100644 index 0000000000..404aa48d8c --- /dev/null +++ b/codex-rs/tui/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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap new file mode 100644 index 0000000000..99d263da31 --- /dev/null +++ b/codex-rs/tui/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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap new file mode 100644 index 0000000000..404aa48d8c --- /dev/null +++ b/codex-rs/tui/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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap new file mode 100644 index 0000000000..c08e7f2d83 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands @ for file paths shift + enter for newline " +" ctrl + v to paste images ctrl + c to exit ctrl + t to view transcript " +" ? to hide shortcuts esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index 263c7cc549..53cfb9d76d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index beb2d8e464..52474a1fd8 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index 41f0f03d8b..d4c6bdedf6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap index 32482ba1cd..31a1b743b8 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -2,6 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ⌃C again to quit " -" " -" " +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap index 8c9c2b0d46..9979372a1b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -2,6 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ⌃C again to interrupt " -" " -" " +" ctrl + c again to interrupt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap new file mode 100644 index 0000000000..b2333b025f --- /dev/null +++ b/codex-rs/tui/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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap new file mode 100644 index 0000000000..20f9b178b4 --- /dev/null +++ b/codex-rs/tui/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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap index 8c8a10df85..aff75fdcdf 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -2,6 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " -" " -" " +"? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 9b94d89f98..96c10733dc 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,6 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use" -" " -" " +" / for commands @ for file paths shift + enter for ne" +" ctrl + v to paste images ctrl + c to exit ctrl + t to view tra" +" ? to hide shortcuts esc again to edit previous message " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0bf77f9994..85ecad284a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -393,7 +393,6 @@ impl ChatWidget { pub(crate) fn set_token_info(&mut self, info: Option) { if info.is_some() { - self.bottom_pane.set_token_usage(info.clone()); self.token_info = info; } } @@ -1960,7 +1959,6 @@ impl ChatWidget { pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; - self.bottom_pane.set_token_usage(None); } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 248a57f8f3..1698f2c77f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -14,4 +14,5 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits - ⏎ send ⌃J newline ⌃T transcript ⌃C quit + + ? for shortcuts diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index 9c94206a44..fcd146282b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -8,5 +8,6 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " +" ? for shortcuts " " " diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 80f91d50b2..f68a493e9c 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -10,20 +10,6 @@ const ALT_PREFIX: &str = "⌥"; #[cfg(all(not(test), not(target_os = "macos")))] const ALT_PREFIX: &str = "Alt+"; -#[cfg(test)] -const CTRL_PREFIX: &str = "⌃"; -#[cfg(all(not(test), target_os = "macos"))] -const CTRL_PREFIX: &str = "⌃"; -#[cfg(all(not(test), not(target_os = "macos")))] -const CTRL_PREFIX: &str = "Ctrl+"; - -#[cfg(test)] -const SHIFT_PREFIX: &str = "⇧"; -#[cfg(all(not(test), target_os = "macos"))] -const SHIFT_PREFIX: &str = "⇧"; -#[cfg(all(not(test), not(target_os = "macos")))] -const SHIFT_PREFIX: &str = "Shift+"; - fn key_hint_style() -> Style { Style::default().bold() } @@ -32,18 +18,6 @@ fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> { Span::styled(format!("{prefix}{key}"), key_hint_style()) } -pub(crate) fn ctrl(key: impl Display) -> Span<'static> { - modifier_span(CTRL_PREFIX, key) -} - pub(crate) fn alt(key: impl Display) -> Span<'static> { modifier_span(ALT_PREFIX, key) } - -pub(crate) fn shift(key: impl Display) -> Span<'static> { - modifier_span(SHIFT_PREFIX, key) -} - -pub(crate) fn plain(key: impl Display) -> Span<'static> { - Span::styled(format!("{key}"), key_hint_style()) -} From a3254696c860d9981e01d97a933522faa8a6247f Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Fri, 26 Sep 2025 21:45:46 -0700 Subject: [PATCH 16/31] docs: refresh README under codex-rs (#4333) --- codex-rs/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index 043d872afe..f51f746892 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -4,18 +4,18 @@ We provide Codex CLI as a standalone, native executable to ensure a zero-depende ## Installing Codex -Today, the easiest way to install Codex is via `npm`, though we plan to publish Codex to other package managers soon. +Today, the easiest way to install Codex is via `npm`: ```shell -npm i -g @openai/codex@native +npm i -g @openai/codex codex ``` -You can also download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). +You can also install via Homebrew (`brew install codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). ## What's new in the Rust CLI -While we are [working to close the gap between the TypeScript and Rust implementations of Codex CLI](https://github.com/openai/codex/issues/1262), note that the Rust CLI has a number of features that the TypeScript CLI does not! +The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported. ### Config From 90c3a5650cfa2ef208a70209303ebacd2091c007 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Fri, 26 Sep 2025 22:31:39 -0700 Subject: [PATCH 17/31] fix: set gpt-5-codex medium preset reasoning (#4335) Otherwise it shows up as none, see https://github.com/openai/codex/issues/4321 --- codex-rs/common/src/model_presets.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 8eb5beacda..1acfee88f6 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -29,7 +29,7 @@ const PRESETS: &[ModelPreset] = &[ label: "gpt-5-codex medium", description: "", model: "gpt-5-codex", - effort: None, + effort: Some(ReasoningEffort::Medium), }, ModelPreset { id: "gpt-5-codex-high", From c0960c0f4955cf529e34d037f6e5794da09795e0 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 26 Sep 2025 22:49:59 -0700 Subject: [PATCH 18/31] tui: separator above final agent message (#4324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a separator line before the final agent message Screenshot 2025-09-26 at 4 55 01 PM --- codex-rs/tui/src/bottom_pane/mod.rs | 4 +++ codex-rs/tui/src/chatwidget.rs | 15 ++++++++ ...et__tests__binary_size_ideal_response.snap | 5 +++ codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/history_cell.rs | 34 +++++++++++++++++++ codex-rs/tui/src/status_indicator_widget.rs | 4 +-- 6 files changed, 61 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b6c93d2813..c65067b581 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -102,6 +102,10 @@ impl BottomPane { } } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { + self.status.as_ref() + } + fn active_view(&self) -> Option<&dyn BottomPaneView> { self.view_stack.last().map(std::convert::AsRef::as_ref) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85ecad284a..9a36addb0a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -254,6 +254,8 @@ pub(crate) struct ChatWidget { // List of ghost commits corresponding to each turn. ghost_snapshots: Vec, ghost_snapshots_disabled: bool, + // Whether to add a final message separator after the last message + needs_final_message_separator: bool, } struct UserMessage { @@ -648,6 +650,14 @@ impl ChatWidget { 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.config.clone())); } if let Some(controller) = self.stream_controller.as_mut() @@ -901,6 +911,7 @@ impl ChatWidget { is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, + needs_final_message_separator: false, } } @@ -962,6 +973,7 @@ impl ChatWidget { is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, + needs_final_message_separator: false, } } @@ -1188,6 +1200,7 @@ impl ChatWidget { 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)); } } @@ -1200,6 +1213,7 @@ impl ChatWidget { 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)); } @@ -1241,6 +1255,7 @@ impl ChatWidget { if !text.is_empty() { self.add_to_history(history_cell::new_user_prompt(text)); } + self.needs_final_message_separator = false; } fn capture_ghost_snapshot(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap index 97709b613c..e3121774f4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1152 expression: "lines[start_idx..].join(\"\\n\")" --- • I need to check the codex-rs repository to explain why the project's binaries @@ -9,6 +10,8 @@ expression: "lines[start_idx..].join(\"\\n\")" 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. @@ -110,6 +113,8 @@ expression: "lines[start_idx..].join(\"\\n\")" "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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0c9ebc73aa..f3799ad336 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -340,6 +340,7 @@ fn make_chatwidget_manual() -> ( is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: false, + needs_final_message_separator: false, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2951ca5b78..f93042f91d 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1090,6 +1090,40 @@ pub(crate) fn new_reasoning_summary_block( Box::new(new_reasoning_block(full_reasoning_buffer, config)) } +#[derive(Debug)] +pub struct FinalMessageSeparator { + elapsed_seconds: Option, +} +impl FinalMessageSeparator { + pub(crate) fn new(elapsed_seconds: Option) -> Self { + Self { elapsed_seconds } + } +} +impl HistoryCell for FinalMessageSeparator { + fn display_lines(&self, width: u16) -> Vec> { + let elapsed_seconds = self + .elapsed_seconds + .map(super::status_indicator_widget::fmt_elapsed_compact); + if let Some(elapsed_seconds) = elapsed_seconds { + let worked_for = format!("─ Worked for {elapsed_seconds} ─"); + let worked_for_width = worked_for.width(); + vec![ + Line::from_iter([ + worked_for, + "─".repeat((width as usize).saturating_sub(worked_for_width)), + ]) + .dim(), + ] + } else { + vec![Line::from_iter(["─".repeat(width as usize).dim()])] + } + } + + fn transcript_lines(&self) -> Vec> { + vec![] + } +} + fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { let args_str = invocation .arguments diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 96f2c49fee..b6fa2fd530 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -34,7 +34,7 @@ pub(crate) struct StatusIndicatorWidget { // Format elapsed seconds into a compact human-friendly form used by the status line. // Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s -fn fmt_elapsed_compact(elapsed_secs: u64) -> String { +pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { if elapsed_secs < 60 { return format!("{elapsed_secs}s"); } @@ -142,7 +142,7 @@ impl StatusIndicatorWidget { elapsed.as_secs() } - fn elapsed_seconds(&self) -> u64 { + pub fn elapsed_seconds(&self) -> u64 { self.elapsed_seconds_at(Instant::now()) } } From 5c67dc3af12ab5426ba5002a90a50b24f1dcf8f9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 26 Sep 2025 22:54:38 -0700 Subject: [PATCH 19/31] Edit the spacing in shortcuts (#4330) image --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 28 ++++--- codex-rs/tui/src/bottom_pane/footer.rs | 76 ++++++++++--------- ...__tests__footer_mode_shortcut_overlay.snap | 6 +- ...tests__footer_shortcuts_shift_and_esc.snap | 6 +- 4 files changed, 63 insertions(+), 53 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3b02c59334..541f3817b4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1204,17 +1204,25 @@ impl ChatComposer { } fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { - if key_event.kind == KeyEventKind::Press - && key_event.modifiers.is_empty() - && matches!(key_event.code, KeyCode::Char('?')) - { - let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); - let changed = next != self.footer_mode; - self.footer_mode = next; - changed - } else { - false + if key_event.kind != KeyEventKind::Press { + return false; } + + let toggles = match key_event.code { + KeyCode::Char('?') if key_event.modifiers.is_empty() => true, + KeyCode::BackTab => true, + KeyCode::Tab if key_event.modifiers.contains(KeyModifiers::SHIFT) => true, + _ => false, + }; + + 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 { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2e63493e31..aa0659ed9f 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -140,6 +140,7 @@ fn build_columns(entries: Vec) -> Vec> { const COLUMNS: usize = 3; const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28]; + const MIN_PADDED_WIDTHS: [usize; COLUMNS - 1] = [22, 0]; let rows = entries.len().div_ceil(COLUMNS); let mut column_widths = [0usize; COLUMNS]; @@ -160,7 +161,8 @@ fn build_columns(entries: Vec) -> Vec> { let entry = &entries[idx]; if col < COLUMNS - 1 { let max_width = MAX_PADDED_WIDTHS[col]; - let target_width = column_widths[col].min(max_width); + let mut target_width = column_widths[col]; + target_width = target_width.max(MIN_PADDED_WIDTHS[col]).min(max_width); let pad_width = target_width + 2; line.push_str(&format!("{entry:) -> Vec> { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShortcutId { Commands, - FilePaths, InsertNewline, + ChangeMode, + FilePaths, PasteImage, + EditPrevious, Quit, ShowTranscript, - ToggleOverlay, - EditPrevious, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -272,17 +274,6 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for commands", }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - code: KeyCode::Char('@'), - modifiers: KeyModifiers::NONE, - overlay_text: "@", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", - }, ShortcutDescriptor { id: ShortcutId::InsertNewline, bindings: &[ @@ -302,6 +293,28 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::ChangeMode, + bindings: &[ShortcutBinding { + code: KeyCode::BackTab, + modifiers: KeyModifiers::SHIFT, + overlay_text: "shift + tab", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to change mode", + }, + ShortcutDescriptor { + id: ShortcutId::FilePaths, + bindings: &[ShortcutBinding { + code: KeyCode::Char('@'), + modifiers: KeyModifiers::NONE, + overlay_text: "@", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for file paths", + }, ShortcutDescriptor { id: ShortcutId::PasteImage, bindings: &[ShortcutBinding { @@ -313,6 +326,17 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to paste images", }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + overlay_text: "esc", + condition: DisplayCondition::Always, + }], + prefix: "", + label: "", + }, ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { @@ -335,28 +359,6 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to view transcript", }, - ShortcutDescriptor { - id: ShortcutId::ToggleOverlay, - bindings: &[ShortcutBinding { - code: KeyCode::Char('?'), - modifiers: KeyModifiers::NONE, - overlay_text: "?", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to hide shortcuts", - }, - ShortcutDescriptor { - id: ShortcutId::EditPrevious, - bindings: &[ShortcutBinding { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - overlay_text: "esc", - condition: DisplayCondition::Always, - }], - prefix: "", - label: "", - }, ]; #[cfg(test)] diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index c08e7f2d83..9633aab2ae 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -10,6 +10,6 @@ expression: terminal.backend() " " " " " " -" / for commands @ for file paths shift + enter for newline " -" ctrl + v to paste images ctrl + c to exit ctrl + t to view transcript " -" ? to hide shortcuts esc again to edit previous message " +" / for commands shift + enter for newline shift + tab to change mode " +" @ 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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 96c10733dc..111c14136a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,6 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands @ for file paths shift + enter for ne" -" ctrl + v to paste images ctrl + c to exit ctrl + t to view tra" -" ? to hide shortcuts esc again to edit previous message " +" / for commands shift + enter for newline shift + tab to change m" +" @ for file paths ctrl + v to paste images esc again to edit previ" +" ctrl + c to exit ctrl + t to view transcript " From 68765214b333a9294e55b7b7c0bd200b93fdd2c8 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 27 Sep 2025 07:54:32 -0700 Subject: [PATCH 20/31] fix: remove default timeout of 30s in the proxy (#4336) This is likely the reason that I saw some conversations "freeze up" when using the proxy. Note the client in `core` does not specify a timeout when making requests to the Responses API, so the proxy should not, either. --- codex-rs/responses-api-proxy/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs index 600a8d6c7b..59c8510beb 100644 --- a/codex-rs/responses-api-proxy/src/lib.rs +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -6,6 +6,7 @@ use std::net::TcpListener; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use anyhow::Context; use anyhow::Result; @@ -62,6 +63,8 @@ pub fn run_main(args: Args) -> Result<()> { .map_err(|err| anyhow!("creating HTTP server: {err}"))?; let client = Arc::new( Client::builder() + // Disable reqwest's 30s default so long-lived response streams keep flowing. + .timeout(None::) .build() .context("building reqwest client")?, ); From bcf2bc0aa5fb45c91ddaa9111cd85fe6a931361f Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:18:33 -0700 Subject: [PATCH 21/31] fix(tui): make `?` work again (#4362) Revert #4330 #4316 --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 247 +-------- codex-rs/tui/src/bottom_pane/footer.rs | 505 ++++++++---------- codex-rs/tui/src/bottom_pane/mod.rs | 8 + ...mposer__tests__backspace_after_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__empty.snap | 2 +- ...__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 - ...r_mode_overlay_then_external_esc_hint.snap | 13 - ...__tests__footer_mode_shortcut_overlay.snap | 15 - ...tom_pane__chat_composer__tests__large.snap | 2 +- ...chat_composer__tests__multiple_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__small.snap | 2 +- ...ooter__tests__footer_ctrl_c_quit_idle.snap | 4 +- ...er__tests__footer_ctrl_c_quit_running.snap | 4 +- ...__footer__tests__footer_esc_hint_idle.snap | 5 - ...footer__tests__footer_esc_hint_primed.snap | 5 - ...oter__tests__footer_shortcuts_default.snap | 4 +- ...tests__footer_shortcuts_shift_and_esc.snap | 6 +- codex-rs/tui/src/chatwidget.rs | 2 + ...exec_and_status_layout_vt100_snapshot.snap | 3 +- ...atwidget__tests__status_widget_active.snap | 3 +- codex-rs/tui/src/key_hint.rs | 26 + 25 files changed, 307 insertions(+), 618 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 541f3817b4..b3c1eea6d9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,3 +1,4 @@ +use codex_core::protocol::TokenUsageInfo; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -18,14 +19,8 @@ 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::prompt_mode; 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 crate::bottom_pane::paste_burst::FlushResult; @@ -79,6 +74,7 @@ pub(crate) struct ChatComposer { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, + token_usage_info: Option, has_focus: bool, attached_images: Vec, placeholder_text: String, @@ -88,7 +84,6 @@ pub(crate) struct ChatComposer { // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, custom_prompts: Vec, - footer_mode: FooterMode, } /// Popup state – at most one can be visible at any time. @@ -98,7 +93,7 @@ enum ActivePopup { File(FileSearchPopup), } -const FOOTER_SPACING_HEIGHT: u16 = 1; +const FOOTER_HINT_HEIGHT: u16 = 1; impl ChatComposer { pub fn new( @@ -122,6 +117,7 @@ impl ChatComposer { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), + token_usage_info: None, has_focus: has_input_focus, attached_images: Vec::new(), placeholder_text, @@ -129,7 +125,6 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), - footer_mode: FooterMode::ShortcutPrompt, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -137,41 +132,26 @@ impl ChatComposer { } pub fn desired_height(&self, width: u16) -> u16 { - let footer_props = self.footer_props(); - let footer_hint_height = footer_height(footer_props); - let footer_spacing = if footer_hint_height > 0 { - FOOTER_SPACING_HEIGHT - } else { - 0 - }; - let footer_total_height = footer_hint_height + footer_spacing; self.textarea .desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) + 2 + match &self.active_popup { - ActivePopup::None => footer_total_height, + ActivePopup::None => FOOTER_HINT_HEIGHT, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), } } fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let footer_props = self.footer_props(); - let footer_hint_height = footer_height(footer_props); - let footer_spacing = if footer_hint_height > 0 { - FOOTER_SPACING_HEIGHT - } else { - 0 - }; - 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::None => Constraint::Max(footer_total_height), + ActivePopup::None => Constraint::Max(FOOTER_HINT_HEIGHT), }; let mut area = area; + // Leave an empty row at the top, unless there isn't room. if area.height > 1 { area.height -= 1; area.y += 1; @@ -195,6 +175,13 @@ impl ChatComposer { self.textarea.is_empty() } + /// Update the cached *context-left* percentage and refresh the placeholder + /// text. The UI relies on the placeholder to convey the remaining + /// context when the composer is empty. + pub(crate) fn set_token_usage(&mut self, token_info: Option) { + self.token_usage_info = token_info; + } + /// 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) { @@ -332,11 +319,6 @@ impl ChatComposer { 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 = prompt_mode(); - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } self.set_has_focus(has_focus); } @@ -372,18 +354,6 @@ impl ChatComposer { /// 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!(); }; @@ -508,18 +478,6 @@ impl ChatComposer { /// 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!(); }; @@ -771,18 +729,6 @@ impl ChatComposer { /// 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 { - 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'), @@ -926,10 +872,6 @@ impl ChatComposer { 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() @@ -1203,47 +1145,6 @@ impl ChatComposer { false } - fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { - if key_event.kind != KeyEventKind::Press { - return false; - } - - let toggles = match key_event.code { - KeyCode::Char('?') if key_event.modifiers.is_empty() => true, - KeyCode::BackTab => true, - KeyCode::Tab if key_event.modifiers.contains(KeyModifiers::SHIFT) => true, - _ => false, - }; - - 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, - } - } - - fn footer_mode(&self) -> FooterMode { - if matches!(self.footer_mode, FooterMode::EscHint) { - FooterMode::EscHint - } else if self.ctrl_c_quit_hint { - FooterMode::CtrlCReminder - } else { - self.footer_mode - } - } - /// 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. @@ -1327,18 +1228,10 @@ impl ChatComposer { pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; - if running { - self.footer_mode = prompt_mode(); - } } 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); - } } } @@ -1359,26 +1252,20 @@ impl WidgetRef for ChatComposer { popup.render_ref(popup_rect, buf); } ActivePopup::None => { - let footer_hint_height = footer_height(self.footer_props()); - let footer_spacing = if footer_hint_height > 0 { - FOOTER_SPACING_HEIGHT - } else { - 0 - }; - 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 - }; - let mut footer_rect = hint_rect; - footer_rect.x = footer_rect.x.saturating_add(2); - footer_rect.width = footer_rect.width.saturating_sub(2); - render_footer(footer_rect, buf, self.footer_props()); + let mut hint_rect = popup_rect; + hint_rect.x += 2; + hint_rect.width = hint_rect.width.saturating_sub(2); + render_footer( + hint_rect, + buf, + FooterProps { + ctrl_c_quit_hint: self.ctrl_c_quit_hint, + is_task_running: self.is_task_running, + esc_backtrack_hint: self.esc_backtrack_hint, + use_shift_enter_hint: self.use_shift_enter_hint, + token_usage_info: self.token_usage_info.as_ref(), + }, + ); } } let style = user_message_style(terminal_palette::default_bg()); @@ -1417,7 +1304,6 @@ mod tests { 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::footer::footer_height; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -1448,7 +1334,7 @@ mod tests { 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") { + if row.contains(" send") { hint_row = Some((y, row)); break; } @@ -1475,81 +1361,6 @@ mod tests { ); } - 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_lines = footer_height(composer.footer_props()); - let height = footer_lines + 8; - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|f| f.render_widget_ref(composer, f.area())) - .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); - }, - ); - } - #[test] fn test_current_at_token_basic_cases() { let test_cases = vec![ diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index aa0659ed9f..77e2c106e5 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,206 +1,180 @@ +use codex_core::protocol::TokenUsageInfo; +use codex_protocol::num_format::format_si_suffix; use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; 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; use ratatui::text::Span; use ratatui::widgets::WidgetRef; +use crate::key_hint; + #[derive(Clone, Copy, Debug)] -pub(crate) struct FooterProps { - pub(crate) mode: FooterMode, +pub(crate) struct FooterProps<'a> { + pub(crate) ctrl_c_quit_hint: bool, + pub(crate) is_task_running: bool, pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, - pub(crate) is_task_running: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum FooterMode { - CtrlCReminder, - ShortcutPrompt, - ShortcutOverlay, - EscHint, -} - -pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { - if ctrl_c_hint { - return current; - } - match current { - FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt, - _ => 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::ShortcutPrompt, - other => other, - } -} - -pub(crate) fn prompt_mode() -> FooterMode { - FooterMode::ShortcutPrompt -} - -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) { - let lines = footer_lines(props); - for (idx, line) in lines.into_iter().enumerate() { - let y = area.y + idx as u16; - if y >= area.y + area.height { - break; - } - let row = Rect::new(area.x, y, area.width, 1); - line.render_ref(row, buf); - } -} - -fn footer_lines(props: FooterProps) -> Vec> { - match props.mode { - FooterMode::CtrlCReminder => { - vec![ctrl_c_reminder_line(CtrlCReminderState { - is_task_running: props.is_task_running, - })] - } - FooterMode::ShortcutPrompt => vec![Line::from(vec!["? for shortcuts".dim()])], - FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - is_task_running: props.is_task_running, - }), - FooterMode::EscHint => { - vec![esc_hint_line(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - is_task_running: props.is_task_running, - })] - } - } + pub(crate) token_usage_info: Option<&'a TokenUsageInfo>, } #[derive(Clone, Copy, Debug)] struct CtrlCReminderState { - is_task_running: bool, + pub(crate) is_task_running: bool, } #[derive(Clone, Copy, Debug)] struct ShortcutsState { - use_shift_enter_hint: bool, - esc_backtrack_hint: bool, - is_task_running: bool, + pub(crate) use_shift_enter_hint: bool, + pub(crate) esc_backtrack_hint: bool, } -fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { - let action = if state.is_task_running { - "interrupt" +#[derive(Clone, Copy, Debug)] +enum FooterContent { + Shortcuts(ShortcutsState), + CtrlCReminder(CtrlCReminderState), +} + +pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) { + let content = if props.ctrl_c_quit_hint { + FooterContent::CtrlCReminder(CtrlCReminderState { + is_task_running: props.is_task_running, + }) } else { - "quit" + FooterContent::Shortcuts(ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + }) }; - Line::from(vec![ - Span::from(format!(" ctrl + c again to {action}")).dim(), - ]) + + let mut spans = footer_spans(content); + if let Some(token_usage_info) = props.token_usage_info { + append_token_usage_spans(&mut spans, token_usage_info); + } + + let spans = spans + .into_iter() + .map(|span| span.patch_style(Style::default().dim())) + .collect::>(); + Line::from(spans).render_ref(area, buf); } -fn esc_hint_line(state: ShortcutsState) -> Line<'static> { - let text = if state.esc_backtrack_hint { - " esc again to edit previous message" - } else { - " esc esc to edit previous message" - }; - Line::from(vec![Span::from(text).dim()]) +fn footer_spans(content: FooterContent) -> Vec> { + match content { + FooterContent::Shortcuts(state) => shortcuts_spans(state), + FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state), + } } -fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { - let mut rendered = Vec::new(); +fn append_token_usage_spans(spans: &mut Vec>, token_usage_info: &TokenUsageInfo) { + let token_usage = &token_usage_info.total_token_usage; + spans.push(" ".into()); + spans.push( + Span::from(format!( + "{} tokens used", + format_si_suffix(token_usage.blended_total()) + )) + .style(Style::default().add_modifier(Modifier::DIM)), + ); + + let last_token_usage = &token_usage_info.last_token_usage; + if let Some(context_window) = token_usage_info.model_context_window { + let percent_remaining: u8 = if context_window > 0 { + last_token_usage.percent_of_context_window_remaining(context_window) + } else { + 100 + }; + + let context_style = if percent_remaining < 20 { + Style::default().fg(Color::Yellow) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + spans.push(" ".into()); + spans.push(Span::styled( + format!("{percent_remaining}% context left"), + context_style, + )); + } +} + +fn shortcuts_spans(state: ShortcutsState) -> Vec> { + let mut spans = Vec::new(); for descriptor in SHORTCUTS { - if let Some(text) = descriptor.overlay_entry(state) { - rendered.push(text); + if let Some(segment) = descriptor.footer_segment(state) { + if !segment.prefix.is_empty() { + spans.push(segment.prefix.into()); + } + spans.push(segment.binding.span()); + spans.push(segment.label.into()); } } - build_columns(rendered) + spans } -fn build_columns(entries: Vec) -> Vec> { - if entries.is_empty() { - return Vec::new(); - } - - const COLUMNS: usize = 3; - const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28]; - const MIN_PADDED_WIDTHS: [usize; COLUMNS - 1] = [22, 0]; - - let rows = entries.len().div_ceil(COLUMNS); - 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.len()); - } - - let mut lines = Vec::new(); - for row in 0..rows { - let mut line = String::from(" "); - for col in 0..COLUMNS { - let idx = row * COLUMNS + col; - if idx >= entries.len() { - continue; - } - let entry = &entries[idx]; - if col < COLUMNS - 1 { - let max_width = MAX_PADDED_WIDTHS[col]; - let mut target_width = column_widths[col]; - target_width = target_width.max(MIN_PADDED_WIDTHS[col]).min(max_width); - let pad_width = target_width + 2; - line.push_str(&format!("{entry: Vec> { + let followup = if state.is_task_running { + " to interrupt" + } else { + " to quit" + }; + vec![ + " ".into(), + key_hint::ctrl('C'), + " again".into(), + followup.into(), + ] } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug)] +struct FooterSegment { + prefix: &'static str, + binding: ShortcutBinding, + label: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] enum ShortcutId { - Commands, + Send, InsertNewline, - ChangeMode, - FilePaths, - PasteImage, - EditPrevious, - Quit, ShowTranscript, + Quit, + EditPrevious, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct ShortcutBinding { code: KeyCode, modifiers: KeyModifiers, - overlay_text: &'static str, + display: ShortcutDisplay, condition: DisplayCondition, } impl ShortcutBinding { - fn matches(&self, state: ShortcutsState) -> bool { - self.condition.matches(state) + fn span(&self) -> Span<'static> { + self.display.into_span() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShortcutDisplay { + Plain(&'static str), + Ctrl(char), + Shift(char), +} + +impl ShortcutDisplay { + fn into_span(self) -> Span<'static> { + match self { + ShortcutDisplay::Plain(text) => key_hint::plain(text), + ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch), + ShortcutDisplay::Shift(ch) => key_hint::shift(ch), + } } } @@ -224,55 +198,49 @@ impl DisplayCondition { struct ShortcutDescriptor { id: ShortcutId, bindings: &'static [ShortcutBinding], - prefix: &'static str, - label: &'static str, + footer_label: &'static str, + footer_prefix: &'static str, } impl ShortcutDescriptor { - fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { - self.bindings.iter().find(|binding| binding.matches(state)) + fn binding_for(&self, state: ShortcutsState) -> Option { + self.bindings + .iter() + .find(|binding| binding.condition.matches(state)) + .copied() } - fn overlay_entry(&self, state: ShortcutsState) -> Option { + fn should_show(&self, state: ShortcutsState) -> bool { + match self.id { + ShortcutId::EditPrevious => state.esc_backtrack_hint, + _ => true, + } + } + + fn footer_segment(&self, state: ShortcutsState) -> Option { + if !self.should_show(state) { + return None; + } let binding = self.binding_for(state)?; - let label = match self.id { - ShortcutId::Quit => { - if state.is_task_running { - " to interrupt" - } else { - self.label - } - } - ShortcutId::EditPrevious => { - if state.esc_backtrack_hint { - " again to edit previous message" - } else { - " esc to edit previous message" - } - } - _ => self.label, - }; - let text = match self.id { - ShortcutId::Quit if state.is_task_running => { - format!("{}{} to interrupt", self.prefix, binding.overlay_text) - } - _ => format!("{}{}{}", self.prefix, binding.overlay_text, label), - }; - Some(text) + Some(FooterSegment { + prefix: self.footer_prefix, + binding, + label: self.footer_label, + }) } } const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { - id: ShortcutId::Commands, + id: ShortcutId::Send, bindings: &[ShortcutBinding { - code: KeyCode::Char('/'), + code: KeyCode::Enter, modifiers: KeyModifiers::NONE, - overlay_text: "/", + display: ShortcutDisplay::Plain("⏎"), condition: DisplayCondition::Always, }], - prefix: "", - label: " for commands", + footer_label: " send ", + footer_prefix: "", }, ShortcutDescriptor { id: ShortcutId::InsertNewline, @@ -280,165 +248,138 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutBinding { code: KeyCode::Enter, modifiers: KeyModifiers::SHIFT, - overlay_text: "shift + enter", + display: ShortcutDisplay::Shift('⏎'), condition: DisplayCondition::WhenShiftEnterHint, }, ShortcutBinding { code: KeyCode::Char('j'), modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + j", + display: ShortcutDisplay::Ctrl('J'), condition: DisplayCondition::WhenNotShiftEnterHint, }, ], - prefix: "", - label: " for newline", - }, - ShortcutDescriptor { - id: ShortcutId::ChangeMode, - bindings: &[ShortcutBinding { - code: KeyCode::BackTab, - modifiers: KeyModifiers::SHIFT, - overlay_text: "shift + tab", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to change mode", - }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - code: KeyCode::Char('@'), - modifiers: KeyModifiers::NONE, - overlay_text: "@", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", - }, - ShortcutDescriptor { - id: ShortcutId::PasteImage, - bindings: &[ShortcutBinding { - code: KeyCode::Char('v'), - modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + v", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to paste images", - }, - ShortcutDescriptor { - id: ShortcutId::EditPrevious, - bindings: &[ShortcutBinding { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - overlay_text: "esc", - condition: DisplayCondition::Always, - }], - prefix: "", - label: "", - }, - ShortcutDescriptor { - id: ShortcutId::Quit, - bindings: &[ShortcutBinding { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + c", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to exit", + footer_label: " newline ", + footer_prefix: "", }, ShortcutDescriptor { id: ShortcutId::ShowTranscript, bindings: &[ShortcutBinding { code: KeyCode::Char('t'), modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + t", + display: ShortcutDisplay::Ctrl('T'), condition: DisplayCondition::Always, }], - prefix: "", - label: " to view transcript", + footer_label: " transcript ", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::Quit, + bindings: &[ShortcutBinding { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + display: ShortcutDisplay::Ctrl('C'), + condition: DisplayCondition::Always, + }], + footer_label: " quit", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + display: ShortcutDisplay::Plain("Esc"), + condition: DisplayCondition::Always, + }], + footer_label: " edit prev", + footer_prefix: " ", }, ]; #[cfg(test)] mod tests { use super::*; + use codex_core::protocol::TokenUsage; 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(); + fn snapshot_footer(name: &str, props: FooterProps<'_>) { + let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap(); terminal .draw(|f| { - let area = Rect::new(0, 0, f.area().width, height); + let area = Rect::new(0, 0, f.area().width, 1); render_footer(area, f.buffer_mut(), props); }) .unwrap(); assert_snapshot!(name, terminal.backend()); } + fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo { + let usage = TokenUsage { + input_tokens: total_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens, + }; + let last = TokenUsage { + input_tokens: last_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: last_tokens, + }; + TokenUsageInfo { + total_token_usage: usage, + last_token_usage: last, + model_context_window: Some(context_window), + } + } + #[test] fn footer_snapshots() { snapshot_footer( "footer_shortcuts_default", FooterProps { - mode: FooterMode::ShortcutPrompt, + ctrl_c_quit_hint: false, + is_task_running: false, esc_backtrack_hint: false, use_shift_enter_hint: false, - is_task_running: false, + token_usage_info: None, }, ); snapshot_footer( "footer_shortcuts_shift_and_esc", FooterProps { - mode: FooterMode::ShortcutOverlay, + ctrl_c_quit_hint: false, + is_task_running: false, esc_backtrack_hint: true, use_shift_enter_hint: true, - is_task_running: false, + token_usage_info: Some(&token_usage(4_200, 900, 8_000)), }, ); snapshot_footer( "footer_ctrl_c_quit_idle", FooterProps { - mode: FooterMode::CtrlCReminder, + ctrl_c_quit_hint: true, + is_task_running: false, esc_backtrack_hint: false, use_shift_enter_hint: false, - is_task_running: false, + token_usage_info: None, }, ); snapshot_footer( "footer_ctrl_c_quit_running", FooterProps { - mode: FooterMode::CtrlCReminder, - esc_backtrack_hint: false, - use_shift_enter_hint: false, + ctrl_c_quit_hint: true, is_task_running: true, - }, - ); - - snapshot_footer( - "footer_esc_hint_idle", - FooterProps { - mode: FooterMode::EscHint, esc_backtrack_hint: false, use_shift_enter_hint: false, - is_task_running: false, - }, - ); - - snapshot_footer( - "footer_esc_hint_primed", - FooterProps { - mode: FooterMode::EscHint, - esc_backtrack_hint: true, - use_shift_enter_hint: false, - is_task_running: false, + token_usage_info: None, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c65067b581..a01ffb498f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use codex_core::protocol::TokenUsageInfo; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -376,6 +377,13 @@ impl BottomPane { !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() } + /// Update the *context-window remaining* indicator in the composer. This + /// is forwarded directly to the underlying `ChatComposer`. + pub(crate) fn set_token_usage(&mut self, token_info: Option) { + self.composer.set_token_usage(token_info); + self.request_redraw(); + } + pub(crate) fn show_view(&mut self, view: Box) { self.push_view(view); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index 3c84d708b0..3f57cab0f6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index 3bdd7260ff..beccbaf7c7 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap deleted file mode 100644 index 6f8ad7b98c..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap deleted file mode 100644 index f7917277fd..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap deleted file mode 100644 index 99d263da31..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap deleted file mode 100644 index 404aa48d8c..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap deleted file mode 100644 index 99d263da31..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap deleted file mode 100644 index 404aa48d8c..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap deleted file mode 100644 index 9633aab2ae..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" / for commands shift + enter for newline shift + tab to change mode " -" @ 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/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index 53cfb9d76d..263c7cc549 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index 52474a1fd8..beb2d8e464 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index d4c6bdedf6..41f0f03d8b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap index 31a1b743b8..32482ba1cd 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -2,4 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ctrl + c again to quit " +" ⌃C again to quit " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap index 9979372a1b..8c9c2b0d46 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -2,4 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ctrl + c again to interrupt " +" ⌃C again to interrupt " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap deleted file mode 100644 index b2333b025f..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap deleted file mode 100644 index 20f9b178b4..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap index aff75fdcdf..8c8a10df85 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -2,4 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -"? for shortcuts " +"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 111c14136a..9b94d89f98 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,6 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands shift + enter for newline shift + tab to change m" -" @ for file paths ctrl + v to paste images esc again to edit previ" -" ctrl + c to exit ctrl + t to view transcript " +"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use" +" " +" " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9a36addb0a..724feb29f2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -395,6 +395,7 @@ impl ChatWidget { pub(crate) fn set_token_info(&mut self, info: Option) { if info.is_some() { + self.bottom_pane.set_token_usage(info.clone()); self.token_info = info; } } @@ -1974,6 +1975,7 @@ impl ChatWidget { pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; + self.bottom_pane.set_token_usage(None); } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 1698f2c77f..248a57f8f3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -14,5 +14,4 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits - - ? for shortcuts + ⏎ send ⌃J newline ⌃T transcript ⌃C quit diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index fcd146282b..9c94206a44 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -8,6 +8,5 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " " diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f68a493e9c..80f91d50b2 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -10,6 +10,20 @@ const ALT_PREFIX: &str = "⌥"; #[cfg(all(not(test), not(target_os = "macos")))] const ALT_PREFIX: &str = "Alt+"; +#[cfg(test)] +const CTRL_PREFIX: &str = "⌃"; +#[cfg(all(not(test), target_os = "macos"))] +const CTRL_PREFIX: &str = "⌃"; +#[cfg(all(not(test), not(target_os = "macos")))] +const CTRL_PREFIX: &str = "Ctrl+"; + +#[cfg(test)] +const SHIFT_PREFIX: &str = "⇧"; +#[cfg(all(not(test), target_os = "macos"))] +const SHIFT_PREFIX: &str = "⇧"; +#[cfg(all(not(test), not(target_os = "macos")))] +const SHIFT_PREFIX: &str = "Shift+"; + fn key_hint_style() -> Style { Style::default().bold() } @@ -18,6 +32,18 @@ fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> { Span::styled(format!("{prefix}{key}"), key_hint_style()) } +pub(crate) fn ctrl(key: impl Display) -> Span<'static> { + modifier_span(CTRL_PREFIX, key) +} + pub(crate) fn alt(key: impl Display) -> Span<'static> { modifier_span(ALT_PREFIX, key) } + +pub(crate) fn shift(key: impl Display) -> Span<'static> { + modifier_span(SHIFT_PREFIX, key) +} + +pub(crate) fn plain(key: impl Display) -> Span<'static> { + Span::styled(format!("{key}"), key_hint_style()) +} From d7286e9829bae93adb8c64f77a32bed23ebf3b71 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sat, 27 Sep 2025 13:25:09 -0700 Subject: [PATCH 22/31] chore: remove model upgrade popup (#4332) --- codex-rs/core/src/internal_storage.rs | 89 ------------ codex-rs/core/src/lib.rs | 1 - codex-rs/tui/src/ascii_animation.rs | 1 + codex-rs/tui/src/lib.rs | 188 +------------------------- codex-rs/tui/src/new_model_popup.rs | 179 ------------------------ 5 files changed, 4 insertions(+), 454 deletions(-) delete mode 100644 codex-rs/core/src/internal_storage.rs delete mode 100644 codex-rs/tui/src/new_model_popup.rs diff --git a/codex-rs/core/src/internal_storage.rs b/codex-rs/core/src/internal_storage.rs deleted file mode 100644 index 666d9287eb..0000000000 --- a/codex-rs/core/src/internal_storage.rs +++ /dev/null @@ -1,89 +0,0 @@ -use anyhow::Context; -use serde::Deserialize; -use serde::Serialize; -use std::io::ErrorKind; -use std::path::Path; -use std::path::PathBuf; - -pub(crate) const INTERNAL_STORAGE_FILE: &str = "internal_storage.json"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InternalStorage { - #[serde(skip)] - storage_path: PathBuf, - #[serde(default = "default_gpt_5_codex_model_prompt_seen")] - pub gpt_5_codex_model_prompt_seen: bool, -} - -const fn default_gpt_5_codex_model_prompt_seen() -> bool { - true -} - -impl Default for InternalStorage { - fn default() -> Self { - Self { - storage_path: PathBuf::new(), - gpt_5_codex_model_prompt_seen: default_gpt_5_codex_model_prompt_seen(), - } - } -} - -// TODO(jif) generalise all the file writers and build proper async channel inserters. -impl InternalStorage { - pub fn load(codex_home: &Path) -> Self { - let storage_path = codex_home.join(INTERNAL_STORAGE_FILE); - - match std::fs::read_to_string(&storage_path) { - Ok(serialized) => match serde_json::from_str::(&serialized) { - Ok(mut storage) => { - storage.storage_path = storage_path; - storage - } - Err(error) => { - tracing::warn!("failed to parse internal storage: {error:?}"); - Self::empty(storage_path) - } - }, - Err(error) => { - if error.kind() == ErrorKind::NotFound { - tracing::debug!( - "internal storage not found at {}; initializing defaults", - storage_path.display() - ); - } else { - tracing::warn!("failed to read internal storage: {error:?}"); - } - Self::empty(storage_path) - } - } - } - - fn empty(storage_path: PathBuf) -> Self { - Self { - storage_path, - ..Default::default() - } - } - - pub async fn persist(&self) -> anyhow::Result<()> { - let serialized = serde_json::to_string_pretty(self)?; - - if let Some(parent) = self.storage_path.parent() { - tokio::fs::create_dir_all(parent).await.with_context(|| { - format!( - "failed to create internal storage directory at {}", - parent.display() - ) - })?; - } - - tokio::fs::write(&self.storage_path, serialized) - .await - .with_context(|| { - format!( - "failed to persist internal storage at {}", - self.storage_path.display() - ) - }) - } -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ad040ec855..f3f6106692 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -29,7 +29,6 @@ mod exec_command; pub mod exec_env; mod flags; pub mod git_info; -pub mod internal_storage; pub mod landlock; mod mcp_connection_manager; mod mcp_tool_call; diff --git a/codex-rs/tui/src/ascii_animation.rs b/codex-rs/tui/src/ascii_animation.rs index ee90191181..b2d9fc1d19 100644 --- a/codex-rs/tui/src/ascii_animation.rs +++ b/codex-rs/tui/src/ascii_animation.rs @@ -90,6 +90,7 @@ impl AsciiAnimation { true } + #[allow(dead_code)] pub(crate) fn request_frame(&self) { self.request_frame.schedule_frame(); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4c5e7ee97c..d82fa19acc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -12,10 +12,8 @@ use codex_core::RolloutRecorder; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; -use codex_core::config::GPT_5_CODEX_MEDIUM_MODEL; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; -use codex_core::config::persist_model_selection; use codex_core::find_conversation_path_by_id_str; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; @@ -54,7 +52,6 @@ pub mod live_wrap; mod markdown; mod markdown_render; mod markdown_stream; -mod new_model_popup; pub mod onboarding; mod pager_overlay; mod render; @@ -79,14 +76,11 @@ pub mod test_backend; #[cfg(not(debug_assertions))] mod updates; -use crate::new_model_popup::ModelUpgradeDecision; -use crate::new_model_popup::run_model_upgrade_popup; use crate::onboarding::TrustDirectorySelection; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; pub use cli::Cli; -use codex_core::internal_storage::InternalStorage; // (tests access modules directly within the crate) @@ -204,8 +198,6 @@ pub async fn run_main( cli_profile_override, )?; - let internal_storage = InternalStorage::load(&config.codex_home); - let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -249,21 +241,14 @@ pub async fn run_main( let _ = tracing_subscriber::registry().with(file_layer).try_init(); - run_ratatui_app( - cli, - config, - internal_storage, - active_profile, - should_show_trust_screen, - ) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app(cli, config, active_profile, should_show_trust_screen) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( cli: Cli, config: Config, - mut internal_storage: InternalStorage, active_profile: Option, should_show_trust_screen: bool, ) -> color_eyre::Result { @@ -389,36 +374,6 @@ async fn run_ratatui_app( resume_picker::ResumeSelection::StartFresh }; - if should_show_model_rollout_prompt( - &cli, - &config, - active_profile.as_deref(), - internal_storage.gpt_5_codex_model_prompt_seen, - ) { - internal_storage.gpt_5_codex_model_prompt_seen = true; - if let Err(e) = internal_storage.persist().await { - error!("Failed to persist internal storage: {e:?}"); - } - - let upgrade_decision = run_model_upgrade_popup(&mut tui).await?; - let switch_to_new_model = upgrade_decision == ModelUpgradeDecision::Switch; - - if switch_to_new_model { - config.model = GPT_5_CODEX_MEDIUM_MODEL.to_owned(); - config.model_reasoning_effort = None; - if let Err(e) = persist_model_selection( - &config.codex_home, - active_profile.as_deref(), - &config.model, - config.model_reasoning_effort, - ) - .await - { - error!("Failed to persist model selection: {e:?}"); - } - } - } - let Cli { prompt, images, .. } = cli; let app_result = App::run( @@ -532,140 +487,3 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool login_status == LoginStatus::NotAuthenticated } - -fn should_show_model_rollout_prompt( - cli: &Cli, - config: &Config, - active_profile: Option<&str>, - gpt_5_codex_model_prompt_seen: bool, -) -> bool { - let login_status = get_login_status(config); - - active_profile.is_none() - && cli.model.is_none() - && !gpt_5_codex_model_prompt_seen - && config.model_provider.requires_openai_auth - && matches!(login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) - && !cli.oss -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - use codex_core::auth::AuthDotJson; - use codex_core::auth::get_auth_file; - use codex_core::auth::login_with_api_key; - use codex_core::auth::write_auth_json; - use codex_core::token_data::IdTokenInfo; - use codex_core::token_data::TokenData; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - - fn get_next_codex_home() -> PathBuf { - static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0); - let mut codex_home = std::env::temp_dir(); - let unique_suffix = format!( - "codex_tui_test_{}_{}", - std::process::id(), - NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed) - ); - codex_home.push(unique_suffix); - codex_home - } - - fn make_config() -> Config { - // Create a unique CODEX_HOME per test to isolate auth.json writes. - let codex_home = get_next_codex_home(); - std::fs::create_dir_all(&codex_home).expect("create unique CODEX_HOME"); - Config::load_from_base_config_with_overrides( - ConfigToml::default(), - ConfigOverrides::default(), - codex_home, - ) - .expect("load default config") - } - - /// Test helper to write an `auth.json` with the requested auth mode into the - /// provided CODEX_HOME directory. This ensures `get_login_status()` reads the - /// intended mode deterministically. - fn set_auth_method(codex_home: &std::path::Path, mode: AuthMode) { - match mode { - AuthMode::ApiKey => { - login_with_api_key(codex_home, "sk-test-key").expect("write api key auth.json"); - } - AuthMode::ChatGPT => { - // Minimal valid JWT payload: header.payload.signature (all base64url, no padding) - const FAKE_JWT: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.e30.c2ln"; // {"alg":"none","typ":"JWT"}.{}."sig" - let mut id_info = IdTokenInfo::default(); - id_info.raw_jwt = FAKE_JWT.to_string(); - let auth = AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: id_info, - access_token: "access-token".to_string(), - refresh_token: "refresh-token".to_string(), - account_id: None, - }), - last_refresh: None, - }; - let file = get_auth_file(codex_home); - write_auth_json(&file, &auth).expect("write chatgpt auth.json"); - } - } - } - - #[test] - fn shows_login_when_not_authenticated() { - let cfg = make_config(); - assert!(should_show_login_screen( - LoginStatus::NotAuthenticated, - &cfg - )); - } - - #[test] - fn shows_model_rollout_prompt_for_default_model() { - let cli = Cli::parse_from(["codex"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ChatGPT); - assert!(should_show_model_rollout_prompt(&cli, &cfg, None, false,)); - } - - #[test] - fn hides_model_rollout_prompt_when_api_auth_mode() { - let cli = Cli::parse_from(["codex"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ApiKey); - assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, false,)); - } - - #[test] - fn hides_model_rollout_prompt_when_marked_seen() { - let cli = Cli::parse_from(["codex"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ChatGPT); - assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, true,)); - } - - #[test] - fn hides_model_rollout_prompt_when_cli_overrides_model() { - let cli = Cli::parse_from(["codex", "--model", "gpt-4.1"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ChatGPT); - assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, false,)); - } - - #[test] - fn hides_model_rollout_prompt_when_profile_active() { - let cli = Cli::parse_from(["codex"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ChatGPT); - assert!(!should_show_model_rollout_prompt( - &cli, - &cfg, - Some("gpt5"), - false, - )); - } -} diff --git a/codex-rs/tui/src/new_model_popup.rs b/codex-rs/tui/src/new_model_popup.rs deleted file mode 100644 index e2389aad59..0000000000 --- a/codex-rs/tui/src/new_model_popup.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::ascii_animation::AsciiAnimation; -use crate::tui::FrameRequester; -use crate::tui::Tui; -use crate::tui::TuiEvent; -use color_eyre::eyre::Result; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::prelude::Widget; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; -use tokio_stream::StreamExt; - -const MIN_ANIMATION_HEIGHT: u16 = 24; -const MIN_ANIMATION_WIDTH: u16 = 60; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum ModelUpgradeDecision { - Switch, - KeepCurrent, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ModelUpgradeOption { - TryNewModel, - KeepCurrent, -} - -struct ModelUpgradePopup { - highlighted: ModelUpgradeOption, - decision: Option, - animation: AsciiAnimation, -} - -impl ModelUpgradePopup { - fn new(request_frame: FrameRequester) -> Self { - Self { - highlighted: ModelUpgradeOption::TryNewModel, - decision: None, - animation: AsciiAnimation::new(request_frame), - } - } - - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => self.highlight(ModelUpgradeOption::TryNewModel), - KeyCode::Down | KeyCode::Char('j') => self.highlight(ModelUpgradeOption::KeepCurrent), - KeyCode::Char('1') => self.select(ModelUpgradeOption::TryNewModel), - KeyCode::Char('2') => self.select(ModelUpgradeOption::KeepCurrent), - KeyCode::Enter => self.select(self.highlighted), - KeyCode::Esc => self.select(ModelUpgradeOption::KeepCurrent), - KeyCode::Char('.') => { - if key_event.modifiers.contains(KeyModifiers::CONTROL) { - let _ = self.animation.pick_random_variant(); - } - } - _ => {} - } - } - - fn highlight(&mut self, option: ModelUpgradeOption) { - if self.highlighted != option { - self.highlighted = option; - self.animation.request_frame(); - } - } - - fn select(&mut self, option: ModelUpgradeOption) { - self.decision = Some(option.into()); - self.animation.request_frame(); - } -} - -impl From for ModelUpgradeDecision { - fn from(option: ModelUpgradeOption) -> Self { - match option { - ModelUpgradeOption::TryNewModel => ModelUpgradeDecision::Switch, - ModelUpgradeOption::KeepCurrent => ModelUpgradeDecision::KeepCurrent, - } - } -} - -impl WidgetRef for &ModelUpgradePopup { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - Clear.render(area, buf); - self.animation.schedule_next_frame(); - - // Skip the animation entirely when the viewport is too small so we don't clip frames. - let show_animation = - area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH; - - let mut lines: Vec = Vec::new(); - if show_animation { - let frame = self.animation.current_frame(); - lines.extend(frame.lines().map(Into::into)); - // Spacer between animation and text content. - lines.push("".into()); - } - - lines.push(Line::from(vec![ - " ".into(), - "Introducing GPT-5-Codex".bold(), - ])); - lines.push("".into()); - lines.push( - " GPT-5-Codex works faster through easy tasks and harder on complex tasks,".into(), - ); - lines.push(" improves on code quality, and is more steerable with AGENTS.md.".into()); - lines.push("".into()); - - let create_option = - |index: usize, option: ModelUpgradeOption, text: &str| -> Line<'static> { - if self.highlighted == option { - Line::from(vec![ - format!("> {}. ", index + 1).cyan(), - text.to_owned().cyan(), - ]) - } else { - format!(" {}. {text}", index + 1).into() - } - }; - - lines.push(create_option( - 0, - ModelUpgradeOption::TryNewModel, - "Try the new GPT-5-Codex model", - )); - lines.push("".into()); - lines.push(create_option( - 1, - ModelUpgradeOption::KeepCurrent, - "Continue using current model", - )); - lines.push("".into()); - lines.push( - " Press Enter to confirm or Esc to keep your current model" - .dim() - .into(), - ); - - Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .render(area, buf); - } -} - -pub(crate) async fn run_model_upgrade_popup(tui: &mut Tui) -> Result { - let mut popup = ModelUpgradePopup::new(tui.frame_requester()); - - tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&popup, frame.area()); - })?; - - let events = tui.event_stream(); - tokio::pin!(events); - while popup.decision.is_none() { - if let Some(event) = events.next().await { - match event { - TuiEvent::Key(key_event) => popup.handle_key_event(key_event), - TuiEvent::Draw => { - let _ = tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&popup, frame.area()); - }); - } - _ => {} - } - } else { - break; - } - } - - Ok(popup.decision.unwrap_or(ModelUpgradeDecision::KeepCurrent)) -} From 9ee6e6f3422f77a7e851264585dfa2abbccdb775 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Sun, 28 Sep 2025 11:46:15 -0700 Subject: [PATCH 23/31] Improve update nudge (#4405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the update nudge larger and adds a link to see latest release: Screenshot 2025-09-28 at 11 19 05 AM --- codex-rs/tui/src/lib.rs | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d82fa19acc..e959973b22 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -273,6 +273,8 @@ async fn run_ratatui_app( // within the TUI scrollback. Building spans keeps styling consistent. #[cfg(not(debug_assertions))] if let Some(latest_version) = updates::get_upgrade_version(&config) { + use crate::history_cell::padded_emoji; + use crate::history_cell::with_border_with_inner_width; use ratatui::style::Stylize as _; use ratatui::text::Line; @@ -280,16 +282,27 @@ async fn run_ratatui_app( let exe = std::env::current_exe()?; let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - let mut lines: Vec> = Vec::new(); - lines.push(Line::from(vec![ - "✨⬆️ Update available!".bold().cyan(), - " ".into(), - format!("{current_version} -> {latest_version}.").into(), - ])); + let mut content_lines: Vec> = vec![ + Line::from(vec![ + padded_emoji("✨").bold().cyan(), + "Update available!".bold().cyan(), + " ".into(), + format!("{current_version} -> {latest_version}.").bold(), + ]), + Line::from(""), + Line::from("See full release notes:"), + Line::from(""), + Line::from( + "https://github.com/openai/codex/releases/latest" + .cyan() + .underlined(), + ), + Line::from(""), + ]; if managed_by_npm { let npm_cmd = "npm install -g @openai/codex@latest"; - lines.push(Line::from(vec![ + content_lines.push(Line::from(vec![ "Run ".into(), npm_cmd.cyan(), " to update.".into(), @@ -298,19 +311,22 @@ async fn run_ratatui_app( && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) { let brew_cmd = "brew upgrade codex"; - lines.push(Line::from(vec![ + content_lines.push(Line::from(vec![ "Run ".into(), brew_cmd.cyan(), " to update.".into(), ])); } else { - lines.push(Line::from(vec![ + content_lines.push(Line::from(vec![ "See ".into(), - "https://github.com/openai/codex/releases/latest".cyan(), - " for the latest releases and installation options.".into(), + "https://github.com/openai/codex".cyan().underlined(), + " for installation options.".into(), ])); } + let viewport_width = tui.terminal.viewport_area.width as usize; + let inner_width = viewport_width.saturating_sub(4).max(1); + let mut lines = with_border_with_inner_width(content_lines, inner_width); lines.push("".into()); tui.insert_history_lines(lines); } From 43615becf0cee2e05e61af0b077869dda0ba7d13 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 14:35:14 -0700 Subject: [PATCH 24/31] chore: move pre_main_hardening() utility into its own crate (#4403) --- codex-rs/Cargo.lock | 9 +++++++- codex-rs/Cargo.toml | 6 ++++-- codex-rs/cli/Cargo.toml | 10 +-------- codex-rs/cli/src/main.rs | 10 +-------- codex-rs/process-hardening/Cargo.toml | 21 +++++++++++++++++++ .../src/lib.rs} | 16 ++++++++++++++ 6 files changed, 51 insertions(+), 21 deletions(-) create mode 100644 codex-rs/process-hardening/Cargo.toml rename codex-rs/{cli/src/pre_main_hardening.rs => process-hardening/src/lib.rs} (84%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eb97878072..00d95eb4e9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -692,12 +692,12 @@ dependencies = [ "codex-exec", "codex-login", "codex-mcp-server", + "codex-process-hardening", "codex-protocol", "codex-protocol-ts", "codex-responses-api-proxy", "codex-tui", "ctor 0.5.0", - "libc", "owo-colors", "predicates", "pretty_assertions", @@ -949,6 +949,13 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-process-hardening" +version = "0.0.0" +dependencies = [ + "libc", +] + [[package]] name = "codex-protocol" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index af111c6f19..ddd7ed51a3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -16,6 +16,7 @@ members = [ "mcp-server", "mcp-types", "ollama", + "process-hardening", "protocol", "protocol-ts", "rmcp-client", @@ -49,10 +50,11 @@ codex-login = { path = "login" } codex-mcp-client = { path = "mcp-client" } codex-mcp-server = { path = "mcp-server" } codex-ollama = { path = "ollama" } +codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } -codex-rmcp-client = { path = "rmcp-client" } codex-protocol-ts = { path = "protocol-ts" } codex-responses-api-proxy = { path = "responses-api-proxy" } +codex-rmcp-client = { path = "rmcp-client" } codex-tui = { path = "tui" } codex-utils-readiness = { path = "utils/readiness" } core_test_support = { path = "core/tests/common" } @@ -83,8 +85,8 @@ dirs = "6" dotenvy = "0.15.7" env-flags = "0.1.1" env_logger = "0.11.5" -eventsource-stream = "0.2.3" escargot = "0.5" +eventsource-stream = "0.2.3" futures = "0.3" icu_decimal = "2.0.0" icu_locale_core = "2.0.0" diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index e61285c822..094a35b42c 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -25,6 +25,7 @@ codex-core = { workspace = true } codex-exec = { workspace = true } codex-login = { workspace = true } codex-mcp-server = { workspace = true } +codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-protocol-ts = { workspace = true } codex-responses-api-proxy = { workspace = true } @@ -43,15 +44,6 @@ tokio = { workspace = true, features = [ tracing = { workspace = true } tracing-subscriber = { workspace = true } -[target.'cfg(target_os = "linux")'.dependencies] -libc = { workspace = true } - -[target.'cfg(target_os = "android")'.dependencies] -libc = { workspace = true } - -[target.'cfg(target_os = "macos")'.dependencies] -libc = { workspace = true } - [dev-dependencies] assert_cmd = { workspace = true } predicates = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 871966b0a7..7dcfdae2f3 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -23,7 +23,6 @@ use std::path::PathBuf; use supports_color::Stream; mod mcp_cmd; -mod pre_main_hardening; use crate::mcp_cmd::McpCli; use crate::proto::ProtoCli; @@ -213,14 +212,7 @@ fn pre_main_hardening() { }; if secure_mode == "1" { - #[cfg(any(target_os = "linux", target_os = "android"))] - crate::pre_main_hardening::pre_main_hardening_linux(); - - #[cfg(target_os = "macos")] - crate::pre_main_hardening::pre_main_hardening_macos(); - - #[cfg(windows)] - crate::pre_main_hardening::pre_main_hardening_windows(); + codex_process_hardening::pre_main_hardening(); } // Always clear this env var so child processes don't inherit it. diff --git a/codex-rs/process-hardening/Cargo.toml b/codex-rs/process-hardening/Cargo.toml new file mode 100644 index 0000000000..7294b6e268 --- /dev/null +++ b/codex-rs/process-hardening/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition = "2024" +name = "codex-process-hardening" +version = { workspace = true } + +[lib] +name = "codex_process_hardening" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "android")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +libc = { workspace = true } diff --git a/codex-rs/cli/src/pre_main_hardening.rs b/codex-rs/process-hardening/src/lib.rs similarity index 84% rename from codex-rs/cli/src/pre_main_hardening.rs rename to codex-rs/process-hardening/src/lib.rs index a75cb3561c..a787b4097d 100644 --- a/codex-rs/cli/src/pre_main_hardening.rs +++ b/codex-rs/process-hardening/src/lib.rs @@ -1,3 +1,19 @@ +/// This is designed to be called pre-main() (using `#[ctor::ctor]`) to perform +/// various process hardening steps, such as +/// - disabling core dumps +/// - disabling ptrace attach on Linux and macOS. +/// - removing dangerous environment variables such as LD_PRELOAD and DYLD_* +pub fn pre_main_hardening() { + #[cfg(any(target_os = "linux", target_os = "android"))] + pre_main_hardening_linux(); + + #[cfg(target_os = "macos")] + pre_main_hardening_macos(); + + #[cfg(windows)] + pre_main_hardening_windows(); +} + #[cfg(any(target_os = "linux", target_os = "android"))] const PRCTL_FAILED_EXIT_CODE: i32 = 5; From 7407469791857c9ec40a1af3c60e7d70c9799554 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 15:13:44 -0700 Subject: [PATCH 25/31] chore: lower logging level from error to info for MCP startup (#4412) --- codex-rs/core/src/mcp_connection_manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 015ca8c6bb..a488791c2b 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -108,7 +108,7 @@ impl McpClientAdapter { params: mcp_types::InitializeRequestParams, startup_timeout: Duration, ) -> Result { - tracing::error!( + info!( "new_stdio_client use_rmcp_client: {use_rmcp_client} program: {program:?} args: {args:?} env: {env:?} params: {params:?} startup_timeout: {startup_timeout:?}" ); if use_rmcp_client { From 99841332e24b5bbd26b0aa3831d7b8d5c3fa640e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 15:22:27 -0700 Subject: [PATCH 26/31] chore: remove responses-api-proxy from the multitool (#4404) This removes the `codex responses-api-proxy` subcommand in favor of running it as a standalone CLI. As part of this change, we: - remove the dependency on `tokio`/`async/await` as well as `codex_arg0` - introduce the use of `pre_main_hardening()` so `CODEX_SECURE_MODE=1` is not required --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/4404). * #4406 * __->__ #4404 * #4403 --- codex-rs/Cargo.lock | 5 ++--- codex-rs/Cargo.toml | 1 - codex-rs/cli/Cargo.toml | 1 - codex-rs/cli/src/main.rs | 11 ----------- codex-rs/responses-api-proxy/Cargo.toml | 6 +++--- codex-rs/responses-api-proxy/src/main.rs | 18 ++++++++---------- 6 files changed, 13 insertions(+), 29 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 00d95eb4e9..fc0eb20a88 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -695,7 +695,6 @@ dependencies = [ "codex-process-hardening", "codex-protocol", "codex-protocol-ts", - "codex-responses-api-proxy", "codex-tui", "ctor 0.5.0", "owo-colors", @@ -996,13 +995,13 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", - "codex-arg0", + "codex-process-hardening", + "ctor 0.5.0", "libc", "reqwest", "serde", "serde_json", "tiny_http", - "tokio", "zeroize", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ddd7ed51a3..862f06aa17 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -53,7 +53,6 @@ codex-ollama = { path = "ollama" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } -codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-tui = { path = "tui" } codex-utils-readiness = { path = "utils/readiness" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 094a35b42c..618f4b7b03 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -28,7 +28,6 @@ codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-protocol-ts = { workspace = true } -codex-responses-api-proxy = { workspace = true } codex-tui = { workspace = true } ctor = { workspace = true } owo-colors = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7dcfdae2f3..5c95df0afa 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,4 +1,3 @@ -use anyhow::Context; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; @@ -15,7 +14,6 @@ use codex_cli::login::run_logout; use codex_cli::proto; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; -use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use owo_colors::OwoColorize; @@ -87,10 +85,6 @@ enum Subcommand { /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), - - /// Internal: run the responses API proxy. - #[clap(hide = true)] - ResponsesApiProxy(ResponsesApiProxyArgs), } #[derive(Debug, Parser)] @@ -339,11 +333,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } - Some(Subcommand::ResponsesApiProxy(args)) => { - tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) - .await - .context("responses-api-proxy blocking task panicked")??; - } } Ok(()) diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml index b66473b195..651019a816 100644 --- a/codex-rs/responses-api-proxy/Cargo.toml +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -8,7 +8,7 @@ name = "codex_responses_api_proxy" path = "src/lib.rs" [[bin]] -name = "responses-api-proxy" +name = "codex-responses-api-proxy" path = "src/main.rs" [lints] @@ -17,11 +17,11 @@ workspace = true [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } -codex-arg0 = { workspace = true } +codex-process-hardening = { workspace = true } +ctor = { workspace = true } libc = { workspace = true } reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny_http = { workspace = true } -tokio = { workspace = true } zeroize = { workspace = true } diff --git a/codex-rs/responses-api-proxy/src/main.rs b/codex-rs/responses-api-proxy/src/main.rs index eaa534d981..c4568d76aa 100644 --- a/codex-rs/responses-api-proxy/src/main.rs +++ b/codex-rs/responses-api-proxy/src/main.rs @@ -1,14 +1,12 @@ -use anyhow::Context; use clap::Parser; -use codex_arg0::arg0_dispatch_or_else; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; -pub fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|_codex_linux_sandbox_exe| async move { - let args = ResponsesApiProxyArgs::parse(); - tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) - .await - .context("responses-api-proxy blocking task panicked")??; - Ok(()) - }) +#[ctor::ctor] +fn pre_main() { + codex_process_hardening::pre_main_hardening(); +} + +pub fn main() -> anyhow::Result<()> { + let args = ResponsesApiProxyArgs::parse(); + codex_responses_api_proxy::run_main(args) } From ff6dbff0b64b0d8aeef947d990adef11770812a4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 15:25:15 -0700 Subject: [PATCH 27/31] feat: build codex-responses-api-proxy for all platforms as part of the GitHub Release (#4406) This should make the `codex-responses-api-proxy` binaries available for all platforms in a GitHub Release as well as a corresponding DotSlash file. Making `codex-responses-api-proxy` available as an `npm` module will be done in a follow-up PR. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/4404). * __->__ #4406 * #4404 * #4403 --- .github/dotslash-config.json | 28 ++++++++++++++++++++++++++ .github/workflows/rust-release.yml | 4 +++- codex-cli/scripts/build_npm_package.py | 7 ++----- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 8566e03490..5e28cdf207 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -27,6 +27,34 @@ "path": "codex.exe" } } + }, + "codex-responses-api-proxy": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-apple-darwin\\.zst$", + "path": "codex-responses-api-proxy" + }, + "macos-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-apple-darwin\\.zst$", + "path": "codex-responses-api-proxy" + }, + "linux-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-unknown-linux-musl\\.zst$", + "path": "codex-responses-api-proxy" + }, + "linux-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-unknown-linux-musl\\.zst$", + "path": "codex-responses-api-proxy" + }, + "windows-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-responses-api-proxy.exe" + }, + "windows-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-responses-api-proxy.exe" + } + } } } } diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c808216d96..345fd091a7 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -97,7 +97,7 @@ jobs: sudo apt install -y musl-tools pkg-config - name: Cargo build - run: cargo build --target ${{ matrix.target }} --release --bin codex + run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy - name: Stage artifacts shell: bash @@ -107,8 +107,10 @@ jobs: if [[ "${{ matrix.runner }}" == windows* ]]; then cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" else cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi - if: ${{ matrix.runner == 'windows-11-arm' }} diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index 0401f10385..bedb1c2438 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -158,11 +158,8 @@ def stage_sources(staging_dir: Path, version: str) -> None: out.write("\n") -def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None: - cmd = ["./scripts/install_native_deps.py"] - if workflow_url: - cmd.extend(["--workflow-url", workflow_url]) - cmd.append(str(staging_dir)) +def install_native_binaries(staging_dir: Path, workflow_url: str) -> None: + cmd = ["./scripts/install_native_deps.py", "--workflow-url", workflow_url, str(staging_dir)] subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT) From 79e51dd60704f9b5a11989dff33ca2e49c26475f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 15:31:17 -0700 Subject: [PATCH 28/31] fix: clean up some minor issues with .github/workflows/ci.yml (#4408) --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cbb1a1983..a10d25f533 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: ci on: - pull_request: { branches: [main] } + pull_request: {} push: { branches: [main] } jobs: @@ -31,6 +31,7 @@ jobs: - uses: facebook/install-dotslash@v2 - name: Stage npm package + id: stage_npm_package env: GH_TOKEN: ${{ github.token }} run: | @@ -40,13 +41,13 @@ jobs: python3 ./codex-cli/scripts/build_npm_package.py \ --release-version "$CODEX_VERSION" \ --pack-output "$PACK_OUTPUT" - echo "PACK_OUTPUT=$PACK_OUTPUT" >> "$GITHUB_ENV" + echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact uses: actions/upload-artifact@v4 with: name: codex-npm-staging - path: ${{ env.PACK_OUTPUT }} + path: ${{ steps.stage_npm_package.outputs.pack_output }} - name: Ensure root README.md contains only ASCII and certain Unicode code points run: ./scripts/asciicheck.py README.md From a9d54b9e92ca8b9f194e77313f016169dcde0e7c Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Sun, 28 Sep 2025 16:59:39 -0700 Subject: [PATCH 29/31] Add /review to main commands (#4416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-09-28 at 4 33 26 PM --- codex-rs/tui/src/history_cell.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index f93042f91d..d0e13003ff 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -414,6 +414,11 @@ pub(crate) fn new_session_info( "/model".into(), " - choose what model and reasoning effort to use".dim(), ]), + Line::from(vec![ + " ".into(), + "/review".into(), + " - review any changes and find issues".dim(), + ]), ]; CompositeHistoryCell { From 618a42adf5ad920dded00b6117330ec28827b4cb Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 19:34:06 -0700 Subject: [PATCH 30/31] feat: introduce npm module for codex-responses-api-proxy (#4417) This PR expands `.github/workflows/rust-release.yml` so that it also builds and publishes the `npm` module for `@openai/codex-responses-api-proxy` in addition to `@openai/codex`. Note both `npm` modules are similar, in that they each contain a single `.js` file that is a thin launcher around the appropriate native executable. (Since we have a minimal dependency on Node.js, I also lowered the minimum version from 20 to 16 and verified that works on my machine.) As part of this change, we tighten up some of the docs around `codex-responses-api-proxy` and ensure the details regarding protecting the `OPENAI_API_KEY` in memory match the implementation. To test the `npm` build process, I ran: ``` ./codex-cli/scripts/build_npm_package.py --package codex-responses-api-proxy --version 0.43.0-alpha.3 ``` which stages the `npm` module for `@openai/codex-responses-api-proxy` in a temp directory, using the binary artifacts from https://github.com/openai/codex/releases/tag/rust-v0.43.0-alpha.3. --- .github/workflows/rust-release.yml | 30 ++++- codex-cli/README.md | 4 +- codex-cli/bin/codex.js | 2 +- codex-cli/package-lock.json | 2 +- codex-cli/package.json | 2 +- codex-cli/scripts/build_npm_package.py | 79 +++++++++--- codex-cli/scripts/install_native_deps.py | 121 ++++++++++++++---- codex-rs/process-hardening/README.md | 7 + codex-rs/responses-api-proxy/README.md | 30 ++++- codex-rs/responses-api-proxy/npm/README.md | 13 ++ .../npm/bin/codex-responses-api-proxy.js | 97 ++++++++++++++ codex-rs/responses-api-proxy/npm/package.json | 21 +++ .../responses-api-proxy/src/read_api_key.rs | 43 ++++++- 13 files changed, 387 insertions(+), 64 deletions(-) create mode 100644 codex-rs/process-hardening/README.md create mode 100644 codex-rs/responses-api-proxy/npm/README.md create mode 100755 codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js create mode 100644 codex-rs/responses-api-proxy/npm/package.json diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 345fd091a7..05bbb9a4d9 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -218,17 +218,30 @@ jobs: # build_npm_package.py requires DotSlash when staging releases. - uses: facebook/install-dotslash@v2 - - name: Stage npm package + - name: Stage codex CLI npm package env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail TMP_DIR="${RUNNER_TEMP}/npm-stage" ./codex-cli/scripts/build_npm_package.py \ + --package codex \ --release-version "${{ steps.release_name.outputs.name }}" \ --staging-dir "${TMP_DIR}" \ --pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz" + - name: Stage responses API proxy npm package + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + TMP_DIR="${RUNNER_TEMP}/npm-stage-responses" + ./codex-cli/scripts/build_npm_package.py \ + --package codex-responses-api-proxy \ + --release-version "${{ steps.release_name.outputs.name }}" \ + --staging-dir "${TMP_DIR}" \ + --pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-responses-api-proxy-npm-${{ steps.release_name.outputs.name }}.tgz" + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -271,7 +284,7 @@ jobs: - name: Update npm run: npm install -g npm@latest - - name: Download npm tarball from release + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -283,6 +296,10 @@ jobs: --repo "${GITHUB_REPOSITORY}" \ --pattern "codex-npm-${version}.tgz" \ --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -296,7 +313,14 @@ jobs: tag_args+=(--tag "${NPM_TAG}") fi - npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${VERSION}.tgz" "${tag_args[@]}" + tarballs=( + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + ) + + for tarball in "${tarballs[@]}"; do + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" + done update-branch: name: Update latest-alpha-cli branch diff --git a/codex-cli/README.md b/codex-cli/README.md index e988b384ab..f3414f1c4b 100644 --- a/codex-cli/README.md +++ b/codex-cli/README.md @@ -208,7 +208,7 @@ The hardening mechanism Codex uses depends on your OS: | Requirement | Details | | --------------------------- | --------------------------------------------------------------- | | Operating systems | macOS 12+, Ubuntu 20.04+/Debian 10+, or Windows 11 **via WSL2** | -| Node.js | **22 or newer** (LTS recommended) | +| Node.js | **16 or newer** (Node 20 LTS recommended) | | Git (optional, recommended) | 2.23+ for built-in PR helpers | | RAM | 4-GB minimum (8-GB recommended) | @@ -513,7 +513,7 @@ Codex runs model-generated commands in a sandbox. If a proposed command or file
Does it work on Windows? -Not directly. It requires [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install) - Codex has been tested on macOS and Linux with Node 22. +Not directly. It requires [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install) - Codex is regularly tested on macOS and Linux with Node 20+, and also supports Node 16.
diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 3621f48c12..17dd98a8e8 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -1,6 +1,7 @@ #!/usr/bin/env node // Unified entry point for the Codex CLI. +import { spawn } from "node:child_process"; import { existsSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -68,7 +69,6 @@ const binaryPath = path.join(archRoot, "codex", codexBinaryName); // executing. This allows us to forward those signals to the child process // and guarantees that when either the child terminates or the parent // receives a fatal signal, both processes exit in a predictable manner. -const { spawn } = await import("child_process"); function getUpdatedPath(newDirs) { const pathSep = process.platform === "win32" ? ";" : ":"; diff --git a/codex-cli/package-lock.json b/codex-cli/package-lock.json index a28bb9cd9f..58ee846306 100644 --- a/codex-cli/package-lock.json +++ b/codex-cli/package-lock.json @@ -11,7 +11,7 @@ "codex": "bin/codex.js" }, "engines": { - "node": ">=20" + "node": ">=16" } } } diff --git a/codex-cli/package.json b/codex-cli/package.json index 03f234d677..b83309e42b 100644 --- a/codex-cli/package.json +++ b/codex-cli/package.json @@ -7,7 +7,7 @@ }, "type": "module", "engines": { - "node": ">=20" + "node": ">=16" }, "files": [ "bin", diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index bedb1c2438..2ae8a96e43 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -13,6 +13,7 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent CODEX_CLI_ROOT = SCRIPT_DIR.parent REPO_ROOT = CODEX_CLI_ROOT.parent +RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" / "npm" GITHUB_REPO = "openai/codex" # The docs are not clear on what the expected value/format of @@ -23,6 +24,12 @@ WORKFLOW_NAME = ".github/workflows/rust-release.yml" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.") + parser.add_argument( + "--package", + choices=("codex", "codex-responses-api-proxy"), + default="codex", + help="Which npm package to stage (default: codex).", + ) parser.add_argument( "--version", help="Version number to write to package.json inside the staged package.", @@ -63,6 +70,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() + package = args.package version = args.version release_version = args.release_version if release_version: @@ -76,7 +84,7 @@ def main() -> int: staging_dir, created_temp = prepare_staging_dir(args.staging_dir) try: - stage_sources(staging_dir, version) + stage_sources(staging_dir, version, package) workflow_url = args.workflow_url resolved_head_sha: str | None = None @@ -100,16 +108,23 @@ def main() -> int: if not workflow_url: raise RuntimeError("Unable to determine workflow URL for native binaries.") - install_native_binaries(staging_dir, workflow_url) + install_native_binaries(staging_dir, workflow_url, package) if release_version: staging_dir_str = str(staging_dir) - print( - f"Staged version {version} for release in {staging_dir_str}\n\n" - "Verify the CLI:\n" - f" node {staging_dir_str}/bin/codex.js --version\n" - f" node {staging_dir_str}/bin/codex.js --help\n\n" - ) + if package == "codex": + print( + f"Staged version {version} for release in {staging_dir_str}\n\n" + "Verify the CLI:\n" + f" node {staging_dir_str}/bin/codex.js --version\n" + f" node {staging_dir_str}/bin/codex.js --help\n\n" + ) + else: + print( + f"Staged version {version} for release in {staging_dir_str}\n\n" + "Verify the responses API proxy:\n" + f" node {staging_dir_str}/bin/codex-responses-api-proxy.js --help\n\n" + ) else: print(f"Staged package in {staging_dir}") @@ -136,20 +151,34 @@ def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]: return temp_dir, True -def stage_sources(staging_dir: Path, version: str) -> None: +def stage_sources(staging_dir: Path, version: str, package: str) -> None: bin_dir = staging_dir / "bin" bin_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js") - rg_manifest = CODEX_CLI_ROOT / "bin" / "rg" - if rg_manifest.exists(): - shutil.copy2(rg_manifest, bin_dir / "rg") + if package == "codex": + shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js") + rg_manifest = CODEX_CLI_ROOT / "bin" / "rg" + if rg_manifest.exists(): + shutil.copy2(rg_manifest, bin_dir / "rg") - readme_src = REPO_ROOT / "README.md" - if readme_src.exists(): - shutil.copy2(readme_src, staging_dir / "README.md") + readme_src = REPO_ROOT / "README.md" + if readme_src.exists(): + shutil.copy2(readme_src, staging_dir / "README.md") - with open(CODEX_CLI_ROOT / "package.json", "r", encoding="utf-8") as fh: + package_json_path = CODEX_CLI_ROOT / "package.json" + elif package == "codex-responses-api-proxy": + launcher_src = RESPONSES_API_PROXY_NPM_ROOT / "bin" / "codex-responses-api-proxy.js" + shutil.copy2(launcher_src, bin_dir / "codex-responses-api-proxy.js") + + readme_src = RESPONSES_API_PROXY_NPM_ROOT / "README.md" + if readme_src.exists(): + shutil.copy2(readme_src, staging_dir / "README.md") + + package_json_path = RESPONSES_API_PROXY_NPM_ROOT / "package.json" + else: + raise RuntimeError(f"Unknown package '{package}'.") + + with open(package_json_path, "r", encoding="utf-8") as fh: package_json = json.load(fh) package_json["version"] = version @@ -158,8 +187,20 @@ def stage_sources(staging_dir: Path, version: str) -> None: out.write("\n") -def install_native_binaries(staging_dir: Path, workflow_url: str) -> None: - cmd = ["./scripts/install_native_deps.py", "--workflow-url", workflow_url, str(staging_dir)] +def install_native_binaries(staging_dir: Path, workflow_url: str, package: str) -> None: + package_components = { + "codex": ["codex", "rg"], + "codex-responses-api-proxy": ["codex-responses-api-proxy"], + } + + components = package_components.get(package) + if components is None: + raise RuntimeError(f"Unknown package '{package}'.") + + cmd = ["./scripts/install_native_deps.py", "--workflow-url", workflow_url] + for component in components: + cmd.extend(["--component", component]) + cmd.append(str(staging_dir)) subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT) diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index 7fbb44393a..8d3909c9e1 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -9,6 +9,7 @@ import subprocess import tarfile import tempfile import zipfile +from dataclasses import dataclass from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Iterable, Sequence @@ -20,7 +21,7 @@ CODEX_CLI_ROOT = SCRIPT_DIR.parent DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0 VENDOR_DIR_NAME = "vendor" RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg" -CODEX_TARGETS = ( +BINARY_TARGETS = ( "x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", @@ -29,6 +30,27 @@ CODEX_TARGETS = ( "aarch64-pc-windows-msvc", ) + +@dataclass(frozen=True) +class BinaryComponent: + artifact_prefix: str # matches the artifact filename prefix (e.g. codex-.zst) + dest_dir: str # directory under vendor// where the binary is installed + binary_basename: str # executable name inside dest_dir (before optional .exe) + + +BINARY_COMPONENTS = { + "codex": BinaryComponent( + artifact_prefix="codex", + dest_dir="codex", + binary_basename="codex", + ), + "codex-responses-api-proxy": BinaryComponent( + artifact_prefix="codex-responses-api-proxy", + dest_dir="codex-responses-api-proxy", + binary_basename="codex-responses-api-proxy", + ), +} + RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [ ("x86_64-unknown-linux-musl", "linux-x86_64"), ("aarch64-unknown-linux-musl", "linux-aarch64"), @@ -50,6 +72,16 @@ def parse_args() -> argparse.Namespace: "known good run when omitted." ), ) + parser.add_argument( + "--component", + dest="components", + action="append", + choices=tuple(list(BINARY_COMPONENTS) + ["rg"]), + help=( + "Limit installation to the specified components." + " May be repeated. Defaults to 'codex' and 'rg'." + ), + ) parser.add_argument( "root", nargs="?", @@ -69,18 +101,28 @@ def main() -> int: vendor_dir = codex_cli_root / VENDOR_DIR_NAME vendor_dir.mkdir(parents=True, exist_ok=True) + components = args.components or ["codex", "rg"] + workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip() if not workflow_url: workflow_url = DEFAULT_WORKFLOW_URL workflow_id = workflow_url.rstrip("/").split("/")[-1] + print(f"Downloading native artifacts from workflow {workflow_id}...") with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str: artifacts_dir = Path(artifacts_dir_str) _download_artifacts(workflow_id, artifacts_dir) - install_codex_binaries(artifacts_dir, vendor_dir, CODEX_TARGETS) + install_binary_components( + artifacts_dir, + vendor_dir, + BINARY_TARGETS, + [name for name in components if name in BINARY_COMPONENTS], + ) - fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST) + if "rg" in components: + print("Fetching ripgrep binaries...") + fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST) print(f"Installed native dependencies into {vendor_dir}") return 0 @@ -124,6 +166,8 @@ def fetch_rg( results: dict[str, Path] = {} max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1))) + print("Installing ripgrep binaries for targets: " + ", ".join(targets)) + with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit( @@ -140,6 +184,7 @@ def fetch_rg( for future in as_completed(future_map): target = future_map[future] results[target] = future.result() + print(f" installed ripgrep for {target}") return [results[target] for target in targets] @@ -158,40 +203,60 @@ def _download_artifacts(workflow_id: str, dest_dir: Path) -> None: subprocess.check_call(cmd) -def install_codex_binaries( - artifacts_dir: Path, vendor_dir: Path, targets: Iterable[str] -) -> list[Path]: +def install_binary_components( + artifacts_dir: Path, + vendor_dir: Path, + targets: Iterable[str], + component_names: Sequence[str], +) -> None: + selected_components = [BINARY_COMPONENTS[name] for name in component_names if name in BINARY_COMPONENTS] + if not selected_components: + return + targets = list(targets) if not targets: - return [] + return - results: dict[str, Path] = {} - max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_map = { - executor.submit(_install_single_codex_binary, artifacts_dir, vendor_dir, target): target - for target in targets - } - - for future in as_completed(future_map): - target = future_map[future] - results[target] = future.result() - - return [results[target] for target in targets] + for component in selected_components: + print( + f"Installing {component.binary_basename} binaries for targets: " + + ", ".join(targets) + ) + max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + _install_single_binary, + artifacts_dir, + vendor_dir, + target, + component, + ): target + for target in targets + } + for future in as_completed(futures): + installed_path = future.result() + print(f" installed {installed_path}") -def _install_single_codex_binary(artifacts_dir: Path, vendor_dir: Path, target: str) -> Path: +def _install_single_binary( + artifacts_dir: Path, + vendor_dir: Path, + target: str, + component: BinaryComponent, +) -> Path: artifact_subdir = artifacts_dir / target - archive_name = _archive_name_for_target(target) + archive_name = _archive_name_for_target(component.artifact_prefix, target) archive_path = artifact_subdir / archive_name if not archive_path.exists(): raise FileNotFoundError(f"Expected artifact not found: {archive_path}") - dest_dir = vendor_dir / target / "codex" + dest_dir = vendor_dir / target / component.dest_dir dest_dir.mkdir(parents=True, exist_ok=True) - binary_name = "codex.exe" if "windows" in target else "codex" + binary_name = ( + f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename + ) dest = dest_dir / binary_name dest.unlink(missing_ok=True) extract_archive(archive_path, "zst", None, dest) @@ -200,10 +265,10 @@ def _install_single_codex_binary(artifacts_dir: Path, vendor_dir: Path, target: return dest -def _archive_name_for_target(target: str) -> str: +def _archive_name_for_target(artifact_prefix: str, target: str) -> str: if "windows" in target: - return f"codex-{target}.exe.zst" - return f"codex-{target}.zst" + return f"{artifact_prefix}-{target}.exe.zst" + return f"{artifact_prefix}-{target}.zst" def _fetch_single_rg( diff --git a/codex-rs/process-hardening/README.md b/codex-rs/process-hardening/README.md new file mode 100644 index 0000000000..66a8060afa --- /dev/null +++ b/codex-rs/process-hardening/README.md @@ -0,0 +1,7 @@ +# codex-process-hardening + +This crate provides `pre_main_hardening()`, which is designed to be called pre-`main()` (using `#[ctor::ctor]`) to perform various process hardening steps, such as + +- disabling core dumps +- disabling ptrace attach on Linux and macOS +- removing dangerous environment variables such as `LD_PRELOAD` and `DYLD_*` diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md index 4f5304a553..4b74fe10d6 100644 --- a/codex-rs/responses-api-proxy/README.md +++ b/codex-rs/responses-api-proxy/README.md @@ -4,12 +4,12 @@ A strict HTTP proxy that only forwards `POST` requests to `/v1/responses` to the ## Expected Usage -**IMPORTANT:** This is designed to be used with `CODEX_SECURE_MODE=1` so that an unprivileged user cannot inspect or tamper with this process. Though if `--http-shutdown` is specified, an unprivileged user _can_ shutdown the server. +**IMPORTANT:** `codex-responses-api-proxy` is designed to be run by a privileged user with access to `OPENAI_API_KEY` so that an unprivileged user cannot inspect or tamper with the process. Though if `--http-shutdown` is specified, an unprivileged user _can_ make a `GET` request to `/shutdown` to shutdown the server, as an unprivileged could not send `SIGTERM` to kill the process. -A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server: +A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server, as `codex-responses-api-proxy` reads the auth token from `stdin`: ```shell -printenv OPENAI_API_KEY | CODEX_SECURE_MODE=1 codex responses-api-proxy --http-shutdown --server-info /tmp/server-info.json +printenv OPENAI_API_KEY | codex-responses-api-proxy --http-shutdown --server-info /tmp/server-info.json ``` A non-privileged user would then run Codex as follows, specifying the `model_provider` dynamically: @@ -22,7 +22,7 @@ codex exec -c "model_providers.openai-proxy={ name = 'OpenAI Proxy', base_url = 'Your prompt here' ``` -When the unprivileged user was finished, they could shutdown the server using `curl` (since `kill -9` is not an option): +When the unprivileged user was finished, they could shutdown the server using `curl` (since `kill -SIGTERM` is not an option): ```shell curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" @@ -30,17 +30,17 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" ## Behavior -- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex responses-api-proxy`). +- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex-responses-api-proxy`). - Formats the header value as `Bearer ` and attempts to `mlock(2)` the memory holding that header so it is not swapped to disk. - Listens on the provided port or an ephemeral port if `--port` is not specified. - Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer ` set. All original request headers (except any incoming `Authorization`) are forwarded upstream. For other requests, it responds with `403`. - Optionally writes a single-line JSON file with server info, currently `{ "port": }`. -- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., root) to start the proxy and another unprivileged user on the host to shut it down. +- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., `root`) to start the proxy and another unprivileged user on the host to shut it down. ## CLI ``` -responses-api-proxy [--port ] [--server-info ] [--http-shutdown] +codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdown] ``` - `--port `: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen. @@ -51,3 +51,19 @@ responses-api-proxy [--port ] [--server-info ] [--http-shutdown] - Only `POST /v1/responses` is permitted. No query strings are allowed. - All request headers are forwarded to the upstream call (aside from overriding `Authorization`). Response status and content-type are mirrored from upstream. + +## Hardening Details + +Care is taken to restrict access/copying to the value of `OPENAI_API_KEY` retained in memory: + +- We leverage [`codex_process_hardening`](https://github.com/openai/codex/blob/main/codex-rs/process-hardening/README.md) so `codex-responses-api-proxy` is run with standard process-hardening techniques. +- At startup, we allocate a `1024` byte buffer on the stack and write `"Bearer "` as the first `7` bytes. +- We then read from `stdin`, copying the contents into the buffer after `"Bearer "`. +- After verifying the key matches `/^[a-zA-Z0-9_-]+$/` (and does not exceed the buffer), we create a `String` from that buffer (so the data is now on the heap). +- We zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler. +- We invoke `.leak()` on the `String` so we can treat its contents as a `&'static str`, as it will live for the rest of the process. +- On UNIX, we `mlock(2)` the memory backing the `&'static str`. +- When using the `&'static str` when building an HTTP request, we use `HeaderValue::from_static()` to avoid copying the `&str`. +- We also invoke `.set_sensitive(true)` on the `HeaderValue`, which in theory indicates to other parts of the HTTP stack that the header should be treated with "special care" to avoid leakage: + +https://github.com/hyperium/http/blob/439d1c50d71e3be3204b6c4a1bf2255ed78e1f93/src/header/value.rs#L346-L376 diff --git a/codex-rs/responses-api-proxy/npm/README.md b/codex-rs/responses-api-proxy/npm/README.md new file mode 100644 index 0000000000..3458e52780 --- /dev/null +++ b/codex-rs/responses-api-proxy/npm/README.md @@ -0,0 +1,13 @@ +# @openai/codex-responses-api-proxy + +

npm i -g @openai/codex-responses-api-proxy to install codex-responses-api-proxy

+ +This package distributes the prebuilt [Codex Responses API proxy binary](https://github.com/openai/codex/tree/main/codex-rs/responses-api-proxy) for macOS, Linux, and Windows. + +To see available options, run: + +``` +node ./bin/codex-responses-api-proxy.js --help +``` + +Refer to [`codex-rs/responses-api-proxy/README.md`](https://github.com/openai/codex/blob/main/codex-rs/responses-api-proxy/README.md) for detailed documentation. diff --git a/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js b/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js new file mode 100755 index 0000000000..e2c3ee7d3e --- /dev/null +++ b/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// Entry point for the Codex responses API proxy binary. + +import { spawn } from "node:child_process"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function determineTargetTriple(platform, arch) { + switch (platform) { + case "linux": + case "android": + if (arch === "x64") { + return "x86_64-unknown-linux-musl"; + } + if (arch === "arm64") { + return "aarch64-unknown-linux-musl"; + } + break; + case "darwin": + if (arch === "x64") { + return "x86_64-apple-darwin"; + } + if (arch === "arm64") { + return "aarch64-apple-darwin"; + } + break; + case "win32": + if (arch === "x64") { + return "x86_64-pc-windows-msvc"; + } + if (arch === "arm64") { + return "aarch64-pc-windows-msvc"; + } + break; + default: + break; + } + return null; +} + +const targetTriple = determineTargetTriple(process.platform, process.arch); +if (!targetTriple) { + throw new Error( + `Unsupported platform: ${process.platform} (${process.arch})`, + ); +} + +const vendorRoot = path.join(__dirname, "..", "vendor"); +const archRoot = path.join(vendorRoot, targetTriple); +const binaryBaseName = "codex-responses-api-proxy"; +const binaryPath = path.join( + archRoot, + binaryBaseName, + process.platform === "win32" ? `${binaryBaseName}.exe` : binaryBaseName, +); + +const child = spawn(binaryPath, process.argv.slice(2), { + stdio: "inherit", +}); + +child.on("error", (err) => { + console.error(err); + process.exit(1); +}); + +const forwardSignal = (signal) => { + if (!child.killed) { + try { + child.kill(signal); + } catch { + /* ignore */ + } + } +}; + +["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => { + process.on(sig, () => forwardSignal(sig)); +}); + +const childResult = await new Promise((resolve) => { + child.on("exit", (code, signal) => { + if (signal) { + resolve({ type: "signal", signal }); + } else { + resolve({ type: "code", exitCode: code ?? 1 }); + } + }); +}); + +if (childResult.type === "signal") { + process.kill(process.pid, childResult.signal); +} else { + process.exit(childResult.exitCode); +} diff --git a/codex-rs/responses-api-proxy/npm/package.json b/codex-rs/responses-api-proxy/npm/package.json new file mode 100644 index 0000000000..f3956a77d6 --- /dev/null +++ b/codex-rs/responses-api-proxy/npm/package.json @@ -0,0 +1,21 @@ +{ + "name": "@openai/codex-responses-api-proxy", + "version": "0.0.0-dev", + "license": "Apache-2.0", + "bin": { + "codex-responses-api-proxy": "bin/codex-responses-api-proxy.js" + }, + "type": "module", + "engines": { + "node": ">=16" + }, + "files": [ + "bin", + "vendor" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/openai/codex.git", + "directory": "codex-rs/responses-api-proxy/npm" + } +} diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs index 8ffad2aab3..1dda92f380 100644 --- a/codex-rs/responses-api-proxy/src/read_api_key.rs +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -54,9 +54,16 @@ where )); } + if let Err(err) = validate_auth_header_bytes(&buf[AUTH_HEADER_PREFIX.len()..total]) { + buf.zeroize(); + return Err(err); + } + let header_str = match std::str::from_utf8(&buf[..total]) { Ok(value) => value, Err(err) => { + // In theory, validate_auth_header_bytes() should have caught + // any invalid UTF-8 sequences, but just in case... buf.zeroize(); return Err(err).context("reading Authorization header from stdin as UTF-8"); } @@ -113,6 +120,21 @@ fn mlock_str(value: &str) { #[cfg(not(unix))] fn mlock_str(_value: &str) {} +/// The key should match /^[A-Za-z0-9\-_]+$/. Ensure there is no funny business +/// with NUL characters and whatnot. +fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> { + if key_bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Ok(()); + } + + Err(anyhow!( + "OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'" + )) +} + #[cfg(test)] mod tests { use super::*; @@ -158,7 +180,7 @@ mod tests { }) .unwrap_err(); let message = format!("{err:#}"); - assert!(message.contains("too large")); + assert!(message.contains("OPENAI_API_KEY is too large to fit in the 512-byte buffer")); } #[test] @@ -180,6 +202,23 @@ mod tests { .unwrap_err(); let message = format!("{err:#}"); - assert!(message.contains("UTF-8")); + assert!( + message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") + ); + } + + #[test] + fn errors_on_invalid_characters() { + let err = read_auth_header_with(|buf| { + let data = b"sk-abc!23"; + buf[..data.len()].copy_from_slice(data); + Ok(data.len()) + }) + .unwrap_err(); + + let message = format!("{err:#}"); + assert!( + message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") + ); } } From c4120a265b9444587d6c69e170b64d2440274e10 Mon Sep 17 00:00:00 2001 From: Ed Bayes Date: Sun, 28 Sep 2025 20:50:04 -0700 Subject: [PATCH 31/31] [CODEX-3595] Remove period when copying highlighted text in iTerm (#4419) --- codex-rs/cli/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 5c95df0afa..ca248229c7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -181,7 +181,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec