diff --git a/feature-a.patch b/feature-a.patch deleted file mode 100644 index 4b36e27e95..0000000000 --- a/feature-a.patch +++ /dev/null @@ -1,1630 +0,0 @@ -From 3b345e0cb4cb78cc61162d38ec5d61b09e7f2418 Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 14:03:35 -0800 -Subject: [PATCH 01/11] user shell command refactor - ---- - codex-rs/core/src/exec.rs | 63 ++++++- - codex-rs/core/src/rollout/policy.rs | 3 + - codex-rs/core/src/sandboxing/mod.rs | 2 +- - codex-rs/core/src/tasks/user_shell.rs | 174 +++++++++++++++--- - codex-rs/core/tests/suite/user_shell_cmd.rs | 12 +- - .../src/event_processor_with_human_output.rs | 3 + - .../src/event_processor_with_jsonl_output.rs | 69 +++++++ - codex-rs/mcp-server/src/codex_tool_runner.rs | 3 + - codex-rs/protocol/src/protocol.rs | 54 ++++++ - codex-rs/tui/src/chatwidget.rs | 153 ++++++++++++--- - codex-rs/tui/src/chatwidget/interrupts.rs | 14 ++ - 11 files changed, 485 insertions(+), 65 deletions(-) - -diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs -index b4dacd9a..aa01923b 100644 ---- a/codex-rs/core/src/exec.rs -+++ b/codex-rs/core/src/exec.rs -@@ -6,6 +6,7 @@ use std::io; - use std::path::Path; - use std::path::PathBuf; - use std::process::ExitStatus; -+use std::sync::Arc; - use std::time::Duration; - use std::time::Instant; - -@@ -23,6 +24,7 @@ use crate::protocol::EventMsg; - use crate::protocol::ExecCommandOutputDeltaEvent; - use crate::protocol::ExecOutputStream; - use crate::protocol::SandboxPolicy; -+use crate::protocol::UserCommandOutputDeltaEvent; - use crate::sandboxing::CommandSpec; - use crate::sandboxing::ExecEnv; - use crate::sandboxing::SandboxManager; -@@ -84,6 +86,41 @@ pub struct StdoutStream { - pub tx_event: Sender, - } - -+#[derive(Clone)] -+pub struct DeltaEventBuilder { -+ inner: Arc) -> EventMsg + Send + Sync>, -+} -+ -+impl DeltaEventBuilder { -+ pub fn exec_command() -> Self { -+ Self { -+ inner: Arc::new(|call_id, stream, chunk| { -+ EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { -+ call_id: call_id.to_string(), -+ stream, -+ chunk, -+ }) -+ }), -+ } -+ } -+ -+ pub fn user_command() -> Self { -+ Self { -+ inner: Arc::new(|call_id, stream, chunk| { -+ EventMsg::UserCommandOutputDelta(UserCommandOutputDeltaEvent { -+ call_id: call_id.to_string(), -+ stream, -+ chunk, -+ }) -+ }), -+ } -+ } -+ -+ pub fn build(&self, call_id: &str, stream: ExecOutputStream, chunk: Vec) -> EventMsg { -+ (self.inner)(call_id, stream, chunk) -+ } -+} -+ - pub async fn process_exec_tool_call( - params: ExecParams, - sandbox_type: SandboxType, -@@ -138,6 +175,7 @@ pub(crate) async fn execute_exec_env( - env: ExecEnv, - sandbox_policy: &SandboxPolicy, - stdout_stream: Option, -+ delta_event_builder: Option, - ) -> Result { - let ExecEnv { - command, -@@ -161,7 +199,15 @@ pub(crate) async fn execute_exec_env( - }; - - let start = Instant::now(); -- let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream).await; -+ let delta_event_builder = delta_event_builder.unwrap_or_else(DeltaEventBuilder::exec_command); -+ let raw_output_result = exec( -+ params, -+ sandbox, -+ sandbox_policy, -+ stdout_stream, -+ delta_event_builder.clone(), -+ ) -+ .await; - let duration = start.elapsed(); - finalize_exec_result(raw_output_result, sandbox, duration) - } -@@ -434,6 +480,7 @@ async fn exec( - sandbox: SandboxType, - sandbox_policy: &SandboxPolicy, - stdout_stream: Option, -+ delta_event_builder: DeltaEventBuilder, - ) -> Result { - #[cfg(target_os = "windows")] - if sandbox == SandboxType::WindowsRestrictedToken { -@@ -465,7 +512,7 @@ async fn exec( - env, - ) - .await?; -- consume_truncated_output(child, timeout, stdout_stream).await -+ consume_truncated_output(child, timeout, stdout_stream, delta_event_builder).await - } - - /// Consumes the output of a child process, truncating it so it is suitable for -@@ -474,6 +521,7 @@ async fn consume_truncated_output( - mut child: Child, - timeout: Duration, - stdout_stream: Option, -+ delta_event_builder: DeltaEventBuilder, - ) -> Result { - // Both stdout and stderr were configured with `Stdio::piped()` - // above, therefore `take()` should normally return `Some`. If it doesn't -@@ -497,12 +545,14 @@ async fn consume_truncated_output( - stdout_stream.clone(), - false, - Some(agg_tx.clone()), -+ delta_event_builder.clone(), - )); - let stderr_handle = tokio::spawn(read_capped( - BufReader::new(stderr_reader), - stdout_stream.clone(), - true, - Some(agg_tx.clone()), -+ delta_event_builder.clone(), - )); - - let (exit_status, timed_out) = tokio::select! { -@@ -554,6 +604,7 @@ async fn read_capped( - stream: Option, - is_stderr: bool, - aggregate_tx: Option>>, -+ delta_event_builder: DeltaEventBuilder, - ) -> io::Result>> { - let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); - let mut tmp = [0u8; READ_CHUNK_SIZE]; -@@ -571,15 +622,15 @@ async fn read_capped( - && emitted_deltas < MAX_EXEC_OUTPUT_DELTAS_PER_CALL - { - let chunk = tmp[..n].to_vec(); -- let msg = EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { -- call_id: stream.call_id.clone(), -- stream: if is_stderr { -+ let msg = delta_event_builder.build( -+ &stream.call_id, -+ if is_stderr { - ExecOutputStream::Stderr - } else { - ExecOutputStream::Stdout - }, - chunk, -- }); -+ ); - let event = Event { - id: stream.sub_id.clone(), - msg, -diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs -index e0088326..afe5c87b 100644 ---- a/codex-rs/core/src/rollout/policy.rs -+++ b/codex-rs/core/src/rollout/policy.rs -@@ -41,6 +41,9 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { - | EventMsg::AgentReasoning(_) - | EventMsg::AgentReasoningRawContent(_) - | EventMsg::TokenCount(_) -+ | EventMsg::UserCommandBegin(_) -+ | EventMsg::UserCommandOutputDelta(_) -+ | EventMsg::UserCommandEnd(_) - | EventMsg::EnteredReviewMode(_) - | EventMsg::ExitedReviewMode(_) - | EventMsg::UndoCompleted(_) -diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs -index 608b39ce..a51a0728 100644 ---- a/codex-rs/core/src/sandboxing/mod.rs -+++ b/codex-rs/core/src/sandboxing/mod.rs -@@ -165,5 +165,5 @@ pub async fn execute_env( - policy: &SandboxPolicy, - stdout_stream: Option, - ) -> crate::error::Result { -- execute_exec_env(env.clone(), policy, stdout_stream).await -+ execute_exec_env(env.clone(), policy, stdout_stream, None).await - } -diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs -index 0e57e1b7..8a6aad0c 100644 ---- a/codex-rs/core/src/tasks/user_shell.rs -+++ b/codex-rs/core/src/tasks/user_shell.rs -@@ -1,28 +1,34 @@ - use std::sync::Arc; -+use std::time::Duration; - - use async_trait::async_trait; --use codex_protocol::models::ShellToolCallParams; -+use codex_protocol::models::ContentItem; -+use codex_protocol::models::ResponseItem; - use codex_protocol::user_input::UserInput; --use tokio::sync::Mutex; - use tokio_util::sync::CancellationToken; - use tracing::error; - use uuid::Uuid; - - use crate::codex::TurnContext; -+use crate::exec::DeltaEventBuilder; -+use crate::exec::SandboxType; -+use crate::exec::StdoutStream; -+use crate::exec::execute_exec_env; -+use crate::exec_env::create_env; -+use crate::parse_command::parse_command; - use crate::protocol::EventMsg; -+use crate::protocol::SandboxPolicy; - use crate::protocol::TaskStartedEvent; -+use crate::protocol::UserCommandBeginEvent; -+use crate::protocol::UserCommandEndEvent; -+use crate::sandboxing::ExecEnv; - use crate::state::TaskKind; --use crate::tools::context::ToolPayload; --use crate::tools::parallel::ToolCallRuntime; --use crate::tools::router::ToolCall; --use crate::tools::router::ToolRouter; --use crate::turn_diff_tracker::TurnDiffTracker; -+use crate::tools::format_exec_output_for_model; -+use crate::tools::format_exec_output_str; - - use super::SessionTask; - use super::SessionTaskContext; - --const USER_SHELL_TOOL_NAME: &str = "local_shell"; -- - #[derive(Clone)] - pub(crate) struct UserShellCommandTask { - command: String, -@@ -78,34 +84,146 @@ impl SessionTask for UserShellCommandTask { - } - }; - -- let params = ShellToolCallParams { -+ fn build_user_message(text: String) -> ResponseItem { -+ ResponseItem::Message { -+ id: None, -+ role: "user".to_string(), -+ content: vec![ContentItem::InputText { text }], -+ } -+ } -+ -+ let call_id = Uuid::new_v4().to_string(); -+ let cwd_display = turn_context.cwd.to_string_lossy(); -+ let raw_command = self.command.clone(); -+ let command_text = format!( -+ "\n{command}\n", -+ cwd = cwd_display, -+ command = raw_command -+ ); -+ let command_items = [build_user_message(command_text)]; -+ session -+ .record_conversation_items(turn_context.as_ref(), &command_items) -+ .await; -+ -+ let parsed_cmd = parse_command(&shell_invocation); -+ session -+ .send_event( -+ turn_context.as_ref(), -+ EventMsg::UserCommandBegin(UserCommandBeginEvent { -+ call_id: call_id.clone(), -+ command: shell_invocation.clone(), -+ cwd: turn_context.cwd.clone(), -+ parsed_cmd, -+ }), -+ ) -+ .await; -+ -+ let exec_env = ExecEnv { - command: shell_invocation, -- workdir: None, -+ cwd: turn_context.cwd.clone(), -+ env: create_env(&turn_context.shell_environment_policy), - timeout_ms: None, -+ sandbox: SandboxType::None, - with_escalated_permissions: None, - justification: None, -+ arg0: None, - }; - -- let tool_call = ToolCall { -- tool_name: USER_SHELL_TOOL_NAME.to_string(), -- call_id: Uuid::new_v4().to_string(), -- payload: ToolPayload::LocalShell { params }, -- }; -+ let stdout_stream = Some(StdoutStream { -+ sub_id: turn_context.sub_id.clone(), -+ call_id: call_id.clone(), -+ tx_event: session.get_tx_event(), -+ }); - -- let router = Arc::new(ToolRouter::from_config(&turn_context.tools_config, None)); -- let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); -- let runtime = ToolCallRuntime::new( -- Arc::clone(&router), -- Arc::clone(&session), -- Arc::clone(&turn_context), -- Arc::clone(&tracker), -+ let sandbox_policy = SandboxPolicy::DangerFullAccess; -+ let exec_future = execute_exec_env( -+ exec_env, -+ &sandbox_policy, -+ stdout_stream, -+ Some(DeltaEventBuilder::user_command()), - ); -+ tokio::pin!(exec_future); -+ -+ let exec_result = tokio::select! { -+ res = &mut exec_future => Some(res), -+ _ = cancellation_token.cancelled() => None, -+ }; - -- if let Err(err) = runtime -- .handle_tool_call(tool_call, cancellation_token) -- .await -- { -- error!("user shell command failed: {err:?}"); -+ match exec_result { -+ None => { -+ let aborted_message = "command aborted by user".to_string(); -+ let aborted_text = format!( -+ "\n{}\n", -+ aborted_message -+ ); -+ let output_items = [build_user_message(aborted_text)]; -+ session -+ .record_conversation_items(turn_context.as_ref(), &output_items) -+ .await; -+ session -+ .send_event( -+ turn_context.as_ref(), -+ EventMsg::UserCommandEnd(UserCommandEndEvent { -+ call_id, -+ stdout: String::new(), -+ stderr: aborted_message.clone(), -+ aggregated_output: aborted_message.clone(), -+ exit_code: -1, -+ duration: Duration::ZERO, -+ formatted_output: aborted_message, -+ }), -+ ) -+ .await; -+ } -+ Some(Ok(output)) => { -+ let formatted_output = format_exec_output_str(&output); -+ session -+ .send_event( -+ turn_context.as_ref(), -+ EventMsg::UserCommandEnd(UserCommandEndEvent { -+ call_id: call_id.clone(), -+ stdout: output.stdout.text.clone(), -+ stderr: output.stderr.text.clone(), -+ aggregated_output: output.aggregated_output.text.clone(), -+ exit_code: output.exit_code, -+ duration: output.duration, -+ formatted_output: formatted_output.clone(), -+ }), -+ ) -+ .await; -+ -+ let output_text = format!( -+ "\n{}\n", -+ format_exec_output_for_model(&output) -+ ); -+ let output_items = [build_user_message(output_text)]; -+ session -+ .record_conversation_items(turn_context.as_ref(), &output_items) -+ .await; -+ } -+ Some(Err(err)) => { -+ error!("user shell command failed: {err:?}"); -+ let message = err.to_string(); -+ session -+ .send_event( -+ turn_context.as_ref(), -+ EventMsg::UserCommandEnd(UserCommandEndEvent { -+ call_id, -+ stdout: String::new(), -+ stderr: message.clone(), -+ aggregated_output: message.clone(), -+ exit_code: -1, -+ duration: Duration::ZERO, -+ formatted_output: message.clone(), -+ }), -+ ) -+ .await; -+ let output_text = format!("\n{}\n", message); -+ let output_items = [build_user_message(output_text)]; -+ session -+ .record_conversation_items(turn_context.as_ref(), &output_items) -+ .await; -+ } - } - None - } -diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs -index 0832d7e6..b8626485 100644 ---- a/codex-rs/core/tests/suite/user_shell_cmd.rs -+++ b/codex-rs/core/tests/suite/user_shell_cmd.rs -@@ -1,9 +1,9 @@ - use codex_core::ConversationManager; - use codex_core::NewConversation; - use codex_core::protocol::EventMsg; --use codex_core::protocol::ExecCommandEndEvent; - use codex_core::protocol::Op; - use codex_core::protocol::TurnAbortReason; -+use codex_core::protocol::UserCommandEndEvent; - use core_test_support::load_default_config_for_test; - use core_test_support::wait_for_event; - use std::path::PathBuf; -@@ -63,8 +63,8 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { - .submit(Op::RunUserShellCommand { command: list_cmd }) - .await - .unwrap(); -- let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await; -- let EventMsg::ExecCommandEnd(ExecCommandEndEvent { -+ let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::UserCommandEnd(_))).await; -+ let EventMsg::UserCommandEnd(UserCommandEndEvent { - stdout, exit_code, .. - }) = msg - else { -@@ -84,8 +84,8 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { - .submit(Op::RunUserShellCommand { command: cat_cmd }) - .await - .unwrap(); -- let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await; -- let EventMsg::ExecCommandEnd(ExecCommandEndEvent { -+ let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::UserCommandEnd(_))).await; -+ let EventMsg::UserCommandEnd(UserCommandEndEvent { - mut stdout, - exit_code, - .. -@@ -128,7 +128,7 @@ async fn user_shell_cmd_can_be_interrupted() { - .unwrap(); - - // Wait until it has started (ExecCommandBegin), then interrupt. -- let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; -+ let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::UserCommandBegin(_))).await; - codex.submit(Op::Interrupt).await.unwrap(); - - // Expect a TurnAborted(Interrupted) notification. -diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs -index 93e0e493..0e1bbe27 100644 ---- a/codex-rs/exec/src/event_processor_with_human_output.rs -+++ b/codex-rs/exec/src/event_processor_with_human_output.rs -@@ -518,6 +518,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { - | EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::ExecCommandOutputDelta(_) -+ | EventMsg::UserCommandBegin(_) -+ | EventMsg::UserCommandOutputDelta(_) -+ | EventMsg::UserCommandEnd(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) -diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs -index 23dff015..348d9350 100644 ---- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs -+++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs -@@ -48,6 +48,8 @@ use codex_core::protocol::PatchApplyEndEvent; - use codex_core::protocol::SessionConfiguredEvent; - use codex_core::protocol::TaskCompleteEvent; - use codex_core::protocol::TaskStartedEvent; -+use codex_core::protocol::UserCommandBeginEvent; -+use codex_core::protocol::UserCommandEndEvent; - use codex_core::protocol::WebSearchEndEvent; - use codex_protocol::plan_tool::StepStatus; - use codex_protocol::plan_tool::UpdatePlanArgs; -@@ -109,6 +111,9 @@ impl EventProcessorWithJsonOutput { - EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), - EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), - EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), -+ EventMsg::UserCommandBegin(ev) => self.handle_user_command_begin(ev), -+ EventMsg::UserCommandEnd(ev) => self.handle_user_command_end(ev), -+ EventMsg::UserCommandOutputDelta(_) => Vec::new(), - EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), - EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), - EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), -@@ -321,6 +326,70 @@ impl EventProcessorWithJsonOutput { - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - -+ fn handle_user_command_begin(&mut self, ev: &UserCommandBeginEvent) -> Vec { -+ let item_id = self.get_next_item_id(); -+ -+ let command_string = match shlex::try_join(ev.command.iter().map(String::as_str)) { -+ Ok(command_string) => command_string, -+ Err(e) => { -+ warn!( -+ call_id = ev.call_id, -+ "Failed to stringify user command: {e:?}; skipping item.started" -+ ); -+ ev.command.join(" ") -+ } -+ }; -+ -+ self.running_commands.insert( -+ ev.call_id.clone(), -+ RunningCommand { -+ command: command_string.clone(), -+ item_id: item_id.clone(), -+ }, -+ ); -+ -+ let item = ThreadItem { -+ id: item_id, -+ details: ThreadItemDetails::CommandExecution(CommandExecutionItem { -+ command: command_string, -+ aggregated_output: String::new(), -+ exit_code: None, -+ status: CommandExecutionStatus::InProgress, -+ }), -+ }; -+ -+ vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] -+ } -+ -+ fn handle_user_command_end(&mut self, ev: &UserCommandEndEvent) -> Vec { -+ let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id) -+ else { -+ warn!( -+ call_id = ev.call_id, -+ "UserCommandEnd without matching UserCommandBegin; skipping item.completed" -+ ); -+ return Vec::new(); -+ }; -+ -+ let status = if ev.exit_code == 0 { -+ CommandExecutionStatus::Completed -+ } else { -+ CommandExecutionStatus::Failed -+ }; -+ -+ let item = ThreadItem { -+ id: item_id, -+ details: ThreadItemDetails::CommandExecution(CommandExecutionItem { -+ command, -+ aggregated_output: ev.aggregated_output.clone(), -+ exit_code: Some(ev.exit_code), -+ status, -+ }), -+ }; -+ -+ vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] -+ } -+ - fn handle_patch_apply_begin(&mut self, ev: &PatchApplyBeginEvent) -> Vec { - self.running_patch_applies - .insert(ev.call_id.clone(), ev.clone()); -diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs -index 96e87515..a1cd35dd 100644 ---- a/codex-rs/mcp-server/src/codex_tool_runner.rs -+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs -@@ -274,6 +274,9 @@ async fn run_codex_tool_session_inner( - | EventMsg::ExecCommandBegin(_) - | EventMsg::ExecCommandOutputDelta(_) - | EventMsg::ExecCommandEnd(_) -+ | EventMsg::UserCommandBegin(_) -+ | EventMsg::UserCommandOutputDelta(_) -+ | EventMsg::UserCommandEnd(_) - | EventMsg::BackgroundEvent(_) - | EventMsg::StreamError(_) - | EventMsg::PatchApplyBegin(_) -diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs -index 2d0b0f01..c811a17b 100644 ---- a/codex-rs/protocol/src/protocol.rs -+++ b/codex-rs/protocol/src/protocol.rs -@@ -493,6 +493,15 @@ pub enum EventMsg { - - ExecCommandEnd(ExecCommandEndEvent), - -+ /// Notification that the user initiated a shell command. -+ UserCommandBegin(UserCommandBeginEvent), -+ -+ /// Incremental chunk of output from a running user command. -+ UserCommandOutputDelta(UserCommandOutputDeltaEvent), -+ -+ /// Completion notification for a user shell command. -+ UserCommandEnd(UserCommandEndEvent), -+ - /// Notification that the agent attached a local image via the view_image tool. - ViewImageToolCall(ViewImageToolCallEvent), - -@@ -1267,6 +1276,51 @@ pub struct ExecCommandOutputDeltaEvent { - pub chunk: Vec, - } - -+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -+pub struct UserCommandBeginEvent { -+ /// Identifier so this can be paired with the UserCommandEnd event. -+ pub call_id: String, -+ /// The command to be executed. -+ pub command: Vec, -+ /// The command's working directory. -+ pub cwd: PathBuf, -+ pub parsed_cmd: Vec, -+} -+ -+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -+pub struct UserCommandEndEvent { -+ /// Identifier for the UserCommandBegin that finished. -+ pub call_id: String, -+ /// Captured stdout. -+ pub stdout: String, -+ /// Captured stderr. -+ pub stderr: String, -+ /// Captured aggregated output. -+ #[serde(default)] -+ pub aggregated_output: String, -+ /// The command's exit code. -+ pub exit_code: i32, -+ /// The duration of the command execution. -+ #[ts(type = "string")] -+ pub duration: Duration, -+ /// Formatted output from the command, as seen by the model. -+ pub formatted_output: String, -+} -+ -+#[serde_as] -+#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] -+pub struct UserCommandOutputDeltaEvent { -+ /// Identifier for the UserCommandBegin that produced this chunk. -+ pub call_id: String, -+ /// Which stream produced this chunk. -+ pub stream: ExecOutputStream, -+ /// Raw bytes from the stream (may not be valid UTF-8). -+ #[serde_as(as = "serde_with::base64::Base64")] -+ #[schemars(with = "String")] -+ #[ts(type = "string")] -+ pub chunk: Vec, -+} -+ - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] - pub struct BackgroundEventEvent { - pub message: String, -diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs -index 5239c66e..f48ed269 100644 ---- a/codex-rs/tui/src/chatwidget.rs -+++ b/codex-rs/tui/src/chatwidget.rs -@@ -40,6 +40,8 @@ use codex_core::protocol::TurnAbortReason; - use codex_core::protocol::TurnDiffEvent; - use codex_core::protocol::UndoCompletedEvent; - use codex_core::protocol::UndoStartedEvent; -+use codex_core::protocol::UserCommandBeginEvent; -+use codex_core::protocol::UserCommandEndEvent; - use codex_core::protocol::UserMessageEvent; - use codex_core::protocol::ViewImageToolCallEvent; - use codex_core::protocol::WarningEvent; -@@ -627,6 +629,29 @@ impl ChatWidget { - self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); - } - -+ fn on_user_command_begin(&mut self, ev: UserCommandBeginEvent) { -+ self.flush_answer_stream_with_separator(); -+ let ev2 = ev.clone(); -+ self.defer_or_handle( -+ |q| q.push_user_command_begin(ev), -+ |s| s.handle_user_command_begin_now(ev2), -+ ); -+ } -+ -+ fn on_user_command_output_delta( -+ &mut self, -+ _ev: codex_core::protocol::UserCommandOutputDeltaEvent, -+ ) { -+ } -+ -+ fn on_user_command_end(&mut self, ev: UserCommandEndEvent) { -+ let ev2 = ev.clone(); -+ self.defer_or_handle( -+ |q| q.push_user_command_end(ev), -+ |s| s.handle_user_command_end_now(ev2), -+ ); -+ } -+ - fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); -@@ -785,11 +810,23 @@ impl ChatWidget { - self.request_redraw(); - } - -- pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { -- let running = self.running_commands.remove(&ev.call_id); -+ fn handle_command_end_internal( -+ &mut self, -+ call_id: String, -+ aggregated_output: String, -+ formatted_output: String, -+ exit_code: i32, -+ duration: std::time::Duration, -+ default_is_user_shell_command: bool, -+ ) { -+ let running = self.running_commands.remove(&call_id); - let (command, parsed, is_user_shell_command) = match running { - Some(rc) => (rc.command, rc.parsed_cmd, rc.is_user_shell_command), -- None => (vec![ev.call_id.clone()], Vec::new(), false), -+ None => ( -+ vec![call_id.clone()], -+ Vec::new(), -+ default_is_user_shell_command, -+ ), - }; - - let needs_new = self -@@ -800,7 +837,7 @@ impl ChatWidget { - if needs_new { - self.flush_active_cell(); - self.active_cell = Some(Box::new(new_active_exec_command( -- ev.call_id.clone(), -+ call_id.clone(), - command, - parsed, - is_user_shell_command, -@@ -813,13 +850,13 @@ impl ChatWidget { - .and_then(|c| c.as_any_mut().downcast_mut::()) - { - cell.complete_call( -- &ev.call_id, -+ &call_id, - CommandOutput { -- exit_code: ev.exit_code, -- formatted_output: ev.formatted_output.clone(), -- aggregated_output: ev.aggregated_output.clone(), -+ exit_code, -+ formatted_output, -+ aggregated_output, - }, -- ev.duration, -+ duration, - ); - if cell.should_flush() { - self.flush_active_cell(); -@@ -827,6 +864,44 @@ impl ChatWidget { - } - } - -+ pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { -+ let ExecCommandEndEvent { -+ call_id, -+ aggregated_output, -+ formatted_output, -+ exit_code, -+ duration, -+ .. -+ } = ev; -+ self.handle_command_end_internal( -+ call_id, -+ aggregated_output, -+ formatted_output, -+ exit_code, -+ duration, -+ false, -+ ); -+ } -+ -+ pub(crate) fn handle_user_command_end_now(&mut self, ev: UserCommandEndEvent) { -+ let UserCommandEndEvent { -+ call_id, -+ aggregated_output, -+ formatted_output, -+ exit_code, -+ duration, -+ .. -+ } = ev; -+ self.handle_command_end_internal( -+ call_id, -+ aggregated_output, -+ formatted_output, -+ exit_code, -+ duration, -+ true, -+ ); -+ } -+ - pub(crate) fn handle_patch_apply_end_now( - &mut self, - event: codex_core::protocol::PatchApplyEndEvent, -@@ -875,14 +950,19 @@ impl ChatWidget { - }); - } - -- pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { -- // Ensure the status indicator is visible while the command runs. -+ fn handle_command_begin_internal( -+ &mut self, -+ call_id: String, -+ command: Vec, -+ parsed_cmd: Vec, -+ is_user_shell_command: bool, -+ ) { - self.running_commands.insert( -- ev.call_id.clone(), -+ call_id.clone(), - RunningCommand { -- command: ev.command.clone(), -- parsed_cmd: ev.parsed_cmd.clone(), -- is_user_shell_command: ev.is_user_shell_command, -+ command: command.clone(), -+ parsed_cmd: parsed_cmd.clone(), -+ is_user_shell_command, - }, - ); - if let Some(cell) = self -@@ -890,10 +970,10 @@ impl ChatWidget { - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - && let Some(new_exec) = cell.with_added_call( -- ev.call_id.clone(), -- ev.command.clone(), -- ev.parsed_cmd.clone(), -- ev.is_user_shell_command, -+ call_id.clone(), -+ command.clone(), -+ parsed_cmd.clone(), -+ is_user_shell_command, - ) - { - *cell = new_exec; -@@ -901,16 +981,37 @@ impl ChatWidget { - self.flush_active_cell(); - - self.active_cell = Some(Box::new(new_active_exec_command( -- ev.call_id.clone(), -- ev.command.clone(), -- ev.parsed_cmd, -- ev.is_user_shell_command, -+ call_id.clone(), -+ command, -+ parsed_cmd, -+ is_user_shell_command, - ))); - } - - self.request_redraw(); - } - -+ pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { -+ let ExecCommandBeginEvent { -+ call_id, -+ command, -+ parsed_cmd, -+ is_user_shell_command, -+ .. -+ } = ev; -+ self.handle_command_begin_internal(call_id, command, parsed_cmd, is_user_shell_command); -+ } -+ -+ pub(crate) fn handle_user_command_begin_now(&mut self, ev: UserCommandBeginEvent) { -+ let UserCommandBeginEvent { -+ call_id, -+ command, -+ parsed_cmd, -+ .. -+ } = ev; -+ self.handle_command_begin_internal(call_id, command, parsed_cmd, true); -+ } -+ - pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { - self.flush_answer_stream_with_separator(); - self.flush_active_cell(); -@@ -1453,7 +1554,8 @@ impl ChatWidget { - match msg { - EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) -- | EventMsg::ExecCommandOutputDelta(_) => {} -+ | EventMsg::ExecCommandOutputDelta(_) -+ | EventMsg::UserCommandOutputDelta(_) => {} - _ => { - tracing::trace!("handle_codex_event: {:?}", msg); - } -@@ -1506,9 +1608,12 @@ impl ChatWidget { - } - EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), - EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), -+ EventMsg::UserCommandBegin(ev) => self.on_user_command_begin(ev), -+ EventMsg::UserCommandOutputDelta(delta) => self.on_user_command_output_delta(delta), - EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), - EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), - EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), -+ EventMsg::UserCommandEnd(ev) => self.on_user_command_end(ev), - EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev), - EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), - EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), -diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs -index 531de3e6..688a3bac 100644 ---- a/codex-rs/tui/src/chatwidget/interrupts.rs -+++ b/codex-rs/tui/src/chatwidget/interrupts.rs -@@ -7,6 +7,8 @@ use codex_core::protocol::ExecCommandEndEvent; - use codex_core::protocol::McpToolCallBeginEvent; - use codex_core::protocol::McpToolCallEndEvent; - use codex_core::protocol::PatchApplyEndEvent; -+use codex_core::protocol::UserCommandBeginEvent; -+use codex_core::protocol::UserCommandEndEvent; - - use super::ChatWidget; - -@@ -16,6 +18,8 @@ pub(crate) enum QueuedInterrupt { - ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), - ExecBegin(ExecCommandBeginEvent), - ExecEnd(ExecCommandEndEvent), -+ UserCommandBegin(UserCommandBeginEvent), -+ UserCommandEnd(UserCommandEndEvent), - McpBegin(McpToolCallBeginEvent), - McpEnd(McpToolCallEndEvent), - PatchEnd(PatchApplyEndEvent), -@@ -59,6 +63,14 @@ impl InterruptManager { - self.queue.push_back(QueuedInterrupt::ExecEnd(ev)); - } - -+ pub(crate) fn push_user_command_begin(&mut self, ev: UserCommandBeginEvent) { -+ self.queue.push_back(QueuedInterrupt::UserCommandBegin(ev)); -+ } -+ -+ pub(crate) fn push_user_command_end(&mut self, ev: UserCommandEndEvent) { -+ self.queue.push_back(QueuedInterrupt::UserCommandEnd(ev)); -+ } -+ - pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) { - self.queue.push_back(QueuedInterrupt::McpBegin(ev)); - } -@@ -80,6 +92,8 @@ impl InterruptManager { - } - QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), - QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), -+ QueuedInterrupt::UserCommandBegin(ev) => chat.handle_user_command_begin_now(ev), -+ QueuedInterrupt::UserCommandEnd(ev) => chat.handle_user_command_end_now(ev), - QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), - QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev), - QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev), --- -2.51.0 - - -From 6e8e5ebdf47166350db4c0cd106cde74ebc0ee0b Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 14:11:11 -0800 -Subject: [PATCH 02/11] fix clippy - ---- - codex-rs/core/src/tasks/user_shell.rs | 15 ++++++--------- - 1 file changed, 6 insertions(+), 9 deletions(-) - -diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs -index 8a6aad0c..904e1db9 100644 ---- a/codex-rs/core/src/tasks/user_shell.rs -+++ b/codex-rs/core/src/tasks/user_shell.rs -@@ -152,10 +152,8 @@ impl SessionTask for UserShellCommandTask { - match exec_result { - None => { - let aborted_message = "command aborted by user".to_string(); -- let aborted_text = format!( -- "\n{}\n", -- aborted_message -- ); -+ let aborted_text = -+ format!("\n{aborted_message}\n"); - let output_items = [build_user_message(aborted_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) -@@ -192,10 +190,9 @@ impl SessionTask for UserShellCommandTask { - ) - .await; - -- let output_text = format!( -- "\n{}\n", -- format_exec_output_for_model(&output) -- ); -+ let output_payload = format_exec_output_for_model(&output); -+ let output_text = -+ format!("\n{output_payload}\n"); - let output_items = [build_user_message(output_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) -@@ -218,7 +215,7 @@ impl SessionTask for UserShellCommandTask { - }), - ) - .await; -- let output_text = format!("\n{}\n", message); -+ let output_text = format!("\n{message}\n"); - let output_items = [build_user_message(output_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) --- -2.51.0 - - -From 29038df46885017ae5c41a6ae274250f422fc9ef Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 14:27:31 -0800 -Subject: [PATCH 03/11] clippy again - ---- - codex-rs/core/src/tasks/user_shell.rs | 4 +--- - 1 file changed, 1 insertion(+), 3 deletions(-) - -diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs -index 904e1db9..37b79550 100644 ---- a/codex-rs/core/src/tasks/user_shell.rs -+++ b/codex-rs/core/src/tasks/user_shell.rs -@@ -96,9 +96,7 @@ impl SessionTask for UserShellCommandTask { - let cwd_display = turn_context.cwd.to_string_lossy(); - let raw_command = self.command.clone(); - let command_text = format!( -- "\n{command}\n", -- cwd = cwd_display, -- command = raw_command -+ "\n{raw_command}\n" - ); - let command_items = [build_user_message(command_text)]; - session --- -2.51.0 - - -From 0d3afbd4992f98f58e87e1d96a18b99120e3cd40 Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 14:36:12 -0800 -Subject: [PATCH 04/11] You Ran - ---- - codex-rs/tui/src/exec_cell/render.rs | 8 +++++++- - 1 file changed, 7 insertions(+), 1 deletion(-) - -diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs -index 4f21f96e..2992df97 100644 ---- a/codex-rs/tui/src/exec_cell/render.rs -+++ b/codex-rs/tui/src/exec_cell/render.rs -@@ -345,7 +345,13 @@ impl ExecCell { - Some(false) => "•".red().bold(), - None => spinner(call.start_time), - }; -- let title = if self.is_active() { "Running" } else { "Ran" }; -+ let title = if self.is_active() { -+ "Running" -+ } else if call.is_user_shell_command { -+ "You Ran" -+ } else { -+ "Ran" -+ }; - - let mut header_line = - Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]); --- -2.51.0 - - -From 4ab82bb5796c911b417172be190e10a56121d91b Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 14:40:31 -0800 -Subject: [PATCH 05/11] clippy - ---- - codex-rs/core/src/exec.rs | 4 +++- - codex-rs/tui/src/chatwidget.rs | 2 +- - 2 files changed, 4 insertions(+), 2 deletions(-) - -diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs -index aa01923b..e9ee9ea8 100644 ---- a/codex-rs/core/src/exec.rs -+++ b/codex-rs/core/src/exec.rs -@@ -86,9 +86,11 @@ pub struct StdoutStream { - pub tx_event: Sender, - } - -+type DeltaEventFn = dyn Fn(&str, ExecOutputStream, Vec) -> EventMsg + Send + Sync; -+ - #[derive(Clone)] - pub struct DeltaEventBuilder { -- inner: Arc) -> EventMsg + Send + Sync>, -+ inner: Arc, - } - - impl DeltaEventBuilder { -diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs -index f48ed269..3901b057 100644 ---- a/codex-rs/tui/src/chatwidget.rs -+++ b/codex-rs/tui/src/chatwidget.rs -@@ -981,7 +981,7 @@ impl ChatWidget { - self.flush_active_cell(); - - self.active_cell = Some(Box::new(new_active_exec_command( -- call_id.clone(), -+ call_id, - command, - parsed_cmd, - is_user_shell_command, --- -2.51.0 - - -From f0eb141e02568ee43638621083d086969338de47 Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 15:44:24 -0800 -Subject: [PATCH 06/11] fixing how we handle err in local shell runs - ---- - codex-rs/core/src/tasks/user_shell.rs | 31 ++++++++++++++++++--------- - 1 file changed, 21 insertions(+), 10 deletions(-) - -diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs -index 37b79550..b16d348d 100644 ---- a/codex-rs/core/src/tasks/user_shell.rs -+++ b/codex-rs/core/src/tasks/user_shell.rs -@@ -11,8 +11,10 @@ use uuid::Uuid; - - use crate::codex::TurnContext; - use crate::exec::DeltaEventBuilder; -+use crate::exec::ExecToolCallOutput; - use crate::exec::SandboxType; - use crate::exec::StdoutStream; -+use crate::exec::StreamOutput; - use crate::exec::execute_exec_env; - use crate::exec_env::create_env; - use crate::parse_command::parse_command; -@@ -172,7 +174,6 @@ impl SessionTask for UserShellCommandTask { - .await; - } - Some(Ok(output)) => { -- let formatted_output = format_exec_output_str(&output); - session - .send_event( - turn_context.as_ref(), -@@ -183,7 +184,7 @@ impl SessionTask for UserShellCommandTask { - aggregated_output: output.aggregated_output.text.clone(), - exit_code: output.exit_code, - duration: output.duration, -- formatted_output: formatted_output.clone(), -+ formatted_output: format_exec_output_str(&output), - }), - ) - .await; -@@ -198,22 +199,32 @@ impl SessionTask for UserShellCommandTask { - } - Some(Err(err)) => { - error!("user shell command failed: {err:?}"); -- let message = err.to_string(); -+ let message = format!("execution error: {err:?}"); -+ let exec_output = ExecToolCallOutput { -+ exit_code: -1, -+ stdout: StreamOutput::new(String::new()), -+ stderr: StreamOutput::new(message.clone()), -+ aggregated_output: StreamOutput::new(message.clone()), -+ duration: Duration::ZERO, -+ timed_out: false, -+ }; - session - .send_event( - turn_context.as_ref(), - EventMsg::UserCommandEnd(UserCommandEndEvent { - call_id, -- stdout: String::new(), -- stderr: message.clone(), -- aggregated_output: message.clone(), -- exit_code: -1, -- duration: Duration::ZERO, -- formatted_output: message.clone(), -+ stdout: exec_output.stdout.text.clone(), -+ stderr: exec_output.stderr.text.clone(), -+ aggregated_output: exec_output.aggregated_output.text.clone(), -+ exit_code: exec_output.exit_code, -+ duration: exec_output.duration, -+ formatted_output: format_exec_output_str(&exec_output), - }), - ) - .await; -- let output_text = format!("\n{message}\n"); -+ let output_payload = format_exec_output_for_model(&exec_output); -+ let output_text = -+ format!("\n{output_payload}\n"); - let output_items = [build_user_message(output_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) --- -2.51.0 - - -From 459c53c45a21ac5e679acd952f0a85e89e1baf0e Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 15:56:16 -0800 -Subject: [PATCH 07/11] user shell command filtering - ---- - codex-rs/core/src/event_mapping.rs | 10 ++++++++-- - codex-rs/core/src/tasks/user_shell.rs | 9 ++++----- - 2 files changed, 12 insertions(+), 7 deletions(-) - -diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs -index c9edd540..8deea96a 100644 ---- a/codex-rs/core/src/event_mapping.rs -+++ b/codex-rs/core/src/event_mapping.rs -@@ -15,6 +15,12 @@ use uuid::Uuid; - - use crate::user_instructions::UserInstructions; - -+fn is_user_shell_command_prefix(text: &str) -> bool { -+ let trimmed = text.trim_start(); -+ let lowered = trimmed.to_ascii_lowercase(); -+ lowered.starts_with("") || lowered.starts_with("") -+} -+ - fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); -@@ -31,7 +37,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option { - for content_item in message.iter() { - match content_item { - ContentItem::InputText { text } => { -- if is_session_prefix(text) { -+ if is_session_prefix(text) || is_user_shell_command_prefix(text) { - return None; - } - content.push(UserInput::Text { text: text.clone() }); -@@ -42,7 +48,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option { - }); - } - ContentItem::OutputText { text } => { -- if is_session_prefix(text) { -+ if is_session_prefix(text) || is_user_shell_command_prefix(text) { - return None; - } - warn!("Output text in user message: {}", text); -diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs -index b16d348d..79d44d98 100644 ---- a/codex-rs/core/src/tasks/user_shell.rs -+++ b/codex-rs/core/src/tasks/user_shell.rs -@@ -95,10 +95,9 @@ impl SessionTask for UserShellCommandTask { - } - - let call_id = Uuid::new_v4().to_string(); -- let cwd_display = turn_context.cwd.to_string_lossy(); - let raw_command = self.command.clone(); - let command_text = format!( -- "\n{raw_command}\n" -+ "\n{raw_command}\n" - ); - let command_items = [build_user_message(command_text)]; - session -@@ -153,7 +152,7 @@ impl SessionTask for UserShellCommandTask { - None => { - let aborted_message = "command aborted by user".to_string(); - let aborted_text = -- format!("\n{aborted_message}\n"); -+ format!("\n{aborted_message}\n"); - let output_items = [build_user_message(aborted_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) -@@ -191,7 +190,7 @@ impl SessionTask for UserShellCommandTask { - - let output_payload = format_exec_output_for_model(&output); - let output_text = -- format!("\n{output_payload}\n"); -+ format!("\n{output_payload}\n"); - let output_items = [build_user_message(output_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) -@@ -224,7 +223,7 @@ impl SessionTask for UserShellCommandTask { - .await; - let output_payload = format_exec_output_for_model(&exec_output); - let output_text = -- format!("\n{output_payload}\n"); -+ format!("\n{output_payload}\n"); - let output_items = [build_user_message(output_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) --- -2.51.0 - - -From 1771759de92e35894f504c859441adb2c4612bbe Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 15:58:26 -0800 -Subject: [PATCH 08/11] fmt - ---- - codex-rs/core/src/event_mapping.rs | 3 ++- - codex-rs/core/src/tasks/user_shell.rs | 19 ++++++++++--------- - 2 files changed, 12 insertions(+), 10 deletions(-) - -diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs -index 8deea96a..f5f71ccb 100644 ---- a/codex-rs/core/src/event_mapping.rs -+++ b/codex-rs/core/src/event_mapping.rs -@@ -18,7 +18,8 @@ use crate::user_instructions::UserInstructions; - fn is_user_shell_command_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); -- lowered.starts_with("") || lowered.starts_with("") -+ lowered.starts_with("") -+ || lowered.starts_with("") - } - - fn is_session_prefix(text: &str) -> bool { -diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs -index 79d44d98..5c793566 100644 ---- a/codex-rs/core/src/tasks/user_shell.rs -+++ b/codex-rs/core/src/tasks/user_shell.rs -@@ -96,9 +96,7 @@ impl SessionTask for UserShellCommandTask { - - let call_id = Uuid::new_v4().to_string(); - let raw_command = self.command.clone(); -- let command_text = format!( -- "\n{raw_command}\n" -- ); -+ let command_text = format!("\n{raw_command}\n"); - let command_items = [build_user_message(command_text)]; - session - .record_conversation_items(turn_context.as_ref(), &command_items) -@@ -151,8 +149,9 @@ impl SessionTask for UserShellCommandTask { - match exec_result { - None => { - let aborted_message = "command aborted by user".to_string(); -- let aborted_text = -- format!("\n{aborted_message}\n"); -+ let aborted_text = format!( -+ "\n{aborted_message}\n" -+ ); - let output_items = [build_user_message(aborted_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) -@@ -189,8 +188,9 @@ impl SessionTask for UserShellCommandTask { - .await; - - let output_payload = format_exec_output_for_model(&output); -- let output_text = -- format!("\n{output_payload}\n"); -+ let output_text = format!( -+ "\n{output_payload}\n" -+ ); - let output_items = [build_user_message(output_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) -@@ -222,8 +222,9 @@ impl SessionTask for UserShellCommandTask { - ) - .await; - let output_payload = format_exec_output_for_model(&exec_output); -- let output_text = -- format!("\n{output_payload}\n"); -+ let output_text = format!( -+ "\n{output_payload}\n" -+ ); - let output_items = [build_user_message(output_text)]; - session - .record_conversation_items(turn_context.as_ref(), &output_items) --- -2.51.0 - - -From c23b8912bdef692265161cb3c092040fbc81b78f Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 16:07:13 -0800 -Subject: [PATCH 09/11] colons - ---- - codex-rs/tui/src/exec_cell/render.rs | 4 ++-- - 1 file changed, 2 insertions(+), 2 deletions(-) - -diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs -index 2992df97..516e65c1 100644 ---- a/codex-rs/tui/src/exec_cell/render.rs -+++ b/codex-rs/tui/src/exec_cell/render.rs -@@ -348,9 +348,9 @@ impl ExecCell { - let title = if self.is_active() { - "Running" - } else if call.is_user_shell_command { -- "You Ran" -+ "You Ran:" - } else { -- "Ran" -+ "Ran:" - }; - - let mut header_line = --- -2.51.0 - - -From 00cb2e20b40c1f706a3a5d8c09fd7b4cd02cc8b4 Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 16:08:28 -0800 -Subject: [PATCH 10/11] add todo comment - ---- - codex-rs/tui/src/chatwidget.rs | 1 + - 1 file changed, 1 insertion(+) - -diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs -index 3901b057..2eb3a375 100644 ---- a/codex-rs/tui/src/chatwidget.rs -+++ b/codex-rs/tui/src/chatwidget.rs -@@ -642,6 +642,7 @@ impl ChatWidget { - &mut self, - _ev: codex_core::protocol::UserCommandOutputDeltaEvent, - ) { -+ // TODO: Handle streaming exec output if/when implemented - } - - fn on_user_command_end(&mut self, ev: UserCommandEndEvent) { --- -2.51.0 - - -From ee32bdb77324f58e8ca0ac465b9a937a4d42a5c0 Mon Sep 17 00:00:00 2001 -From: kevin zhao -Date: Tue, 4 Nov 2025 16:41:00 -0800 -Subject: [PATCH 11/11] commonizing logic - ---- - .../src/event_processor_with_jsonl_output.rs | 153 ++++++++---------- - 1 file changed, 64 insertions(+), 89 deletions(-) - -diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs -index 348d9350..76e73e82 100644 ---- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs -+++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs -@@ -200,22 +200,28 @@ impl EventProcessorWithJsonOutput { - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } -- fn handle_exec_command_begin(&mut self, ev: &ExecCommandBeginEvent) -> Vec { -+ -+ fn start_command_item( -+ &mut self, -+ call_id: &str, -+ command: &[String], -+ warn_subject: &'static str, -+ ) -> Vec { - let item_id = self.get_next_item_id(); - -- let command_string = match shlex::try_join(ev.command.iter().map(String::as_str)) { -+ let command_string = match shlex::try_join(command.iter().map(String::as_str)) { - Ok(command_string) => command_string, - Err(e) => { - warn!( -- call_id = ev.call_id, -- "Failed to stringify command: {e:?}; skipping item.started" -+ call_id = call_id, -+ "Failed to stringify {warn_subject}: {e:?}; skipping item.started" - ); -- ev.command.join(" ") -+ command.join(" ") - } - }; - - self.running_commands.insert( -- ev.call_id.clone(), -+ call_id.to_string(), - RunningCommand { - command: command_string.clone(), - item_id: item_id.clone(), -@@ -235,6 +241,45 @@ impl EventProcessorWithJsonOutput { - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - -+ fn finish_command_item( -+ &mut self, -+ call_id: &str, -+ aggregated_output: &str, -+ exit_code: i32, -+ warn_event_names: (&'static str, &'static str), -+ ) -> Vec { -+ let some_running_command = self.running_commands.remove(call_id); -+ let Some(RunningCommand { command, item_id }) = some_running_command else { -+ let (end_event, begin_event) = warn_event_names; -+ warn!( -+ call_id = call_id, -+ "{end_event} without matching {begin_event}; skipping item.completed" -+ ); -+ return Vec::new(); -+ }; -+ -+ let status = if exit_code == 0 { -+ CommandExecutionStatus::Completed -+ } else { -+ CommandExecutionStatus::Failed -+ }; -+ -+ let item = ThreadItem { -+ id: item_id, -+ details: ThreadItemDetails::CommandExecution(CommandExecutionItem { -+ command, -+ aggregated_output: aggregated_output.to_string(), -+ exit_code: Some(exit_code), -+ status, -+ }), -+ }; -+ -+ vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] -+ } -+ fn handle_exec_command_begin(&mut self, ev: &ExecCommandBeginEvent) -> Vec { -+ self.start_command_item(&ev.call_id, &ev.command, "command") -+ } -+ - fn handle_mcp_tool_call_begin(&mut self, ev: &McpToolCallBeginEvent) -> Vec { - let item_id = self.get_next_item_id(); - let server = ev.invocation.server.clone(); -@@ -327,67 +372,16 @@ impl EventProcessorWithJsonOutput { - } - - fn handle_user_command_begin(&mut self, ev: &UserCommandBeginEvent) -> Vec { -- let item_id = self.get_next_item_id(); -- -- let command_string = match shlex::try_join(ev.command.iter().map(String::as_str)) { -- Ok(command_string) => command_string, -- Err(e) => { -- warn!( -- call_id = ev.call_id, -- "Failed to stringify user command: {e:?}; skipping item.started" -- ); -- ev.command.join(" ") -- } -- }; -- -- self.running_commands.insert( -- ev.call_id.clone(), -- RunningCommand { -- command: command_string.clone(), -- item_id: item_id.clone(), -- }, -- ); -- -- let item = ThreadItem { -- id: item_id, -- details: ThreadItemDetails::CommandExecution(CommandExecutionItem { -- command: command_string, -- aggregated_output: String::new(), -- exit_code: None, -- status: CommandExecutionStatus::InProgress, -- }), -- }; -- -- vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] -+ self.start_command_item(&ev.call_id, &ev.command, "user command") - } - - fn handle_user_command_end(&mut self, ev: &UserCommandEndEvent) -> Vec { -- let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id) -- else { -- warn!( -- call_id = ev.call_id, -- "UserCommandEnd without matching UserCommandBegin; skipping item.completed" -- ); -- return Vec::new(); -- }; -- -- let status = if ev.exit_code == 0 { -- CommandExecutionStatus::Completed -- } else { -- CommandExecutionStatus::Failed -- }; -- -- let item = ThreadItem { -- id: item_id, -- details: ThreadItemDetails::CommandExecution(CommandExecutionItem { -- command, -- aggregated_output: ev.aggregated_output.clone(), -- exit_code: Some(ev.exit_code), -- status, -- }), -- }; -- -- vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] -+ self.finish_command_item( -+ &ev.call_id, -+ &ev.aggregated_output, -+ ev.exit_code, -+ ("UserCommandEnd", "UserCommandBegin"), -+ ) - } - - fn handle_patch_apply_begin(&mut self, ev: &PatchApplyBeginEvent) -> Vec { -@@ -435,31 +429,12 @@ impl EventProcessorWithJsonOutput { - } - - fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec { -- let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id) -- else { -- warn!( -- call_id = ev.call_id, -- "ExecCommandEnd without matching ExecCommandBegin; skipping item.completed" -- ); -- return Vec::new(); -- }; -- let status = if ev.exit_code == 0 { -- CommandExecutionStatus::Completed -- } else { -- CommandExecutionStatus::Failed -- }; -- let item = ThreadItem { -- id: item_id, -- -- details: ThreadItemDetails::CommandExecution(CommandExecutionItem { -- command, -- aggregated_output: ev.aggregated_output.clone(), -- exit_code: Some(ev.exit_code), -- status, -- }), -- }; -- -- vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] -+ self.finish_command_item( -+ &ev.call_id, -+ &ev.aggregated_output, -+ ev.exit_code, -+ ("ExecCommandEnd", "ExecCommandBegin"), -+ ) - } - - fn todo_items_from_plan(&self, args: &UpdatePlanArgs) -> Vec { --- -2.51.0 -