From 0f2e82367ce3ab5cbaab7487c30f999c698f7712 Mon Sep 17 00:00:00 2001 From: Charlie Weems Date: Tue, 9 Sep 2025 00:52:20 -0700 Subject: [PATCH] Implement bash command passthrough --- codex-rs/tui/src/app.rs | 13 ++++ codex-rs/tui/src/app_event.rs | 14 ++++ codex-rs/tui/src/chatwidget.rs | 133 +++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 12cd789c5f..f0b425f47b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -259,6 +259,19 @@ impl App { AppEvent::ConversationHistory(ev) => { self.on_conversation_history_for_backtrack(tui, ev).await?; } + AppEvent::LocalExecResult { + call_id, + command, + parsed_cmd, + exit_code, + stdout, + stderr, + duration, + } => { + self.chat_widget.on_local_exec_result( + call_id, command, parsed_cmd, exit_code, stdout, stderr, duration, + ); + } AppEvent::ExitRequest => { return Ok(false); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1896f0a46e..23edbaa10d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -59,4 +59,18 @@ pub(crate) enum AppEvent { /// Forwarded conversation history snapshot from the current conversation. ConversationHistory(ConversationHistoryResponseEvent), + + /// Result of a locally executed shell command triggered by a leading '!'. + /// Identified by `call_id` that was assigned when starting the command. + LocalExecResult { + call_id: String, + /// Full argv used for display (e.g., ["bash","-lc", "echo hi"]). + command: Vec, + /// Parsed metadata used to render exploring-style summaries when applicable. + parsed_cmd: Vec, + exit_code: i32, + stdout: String, + stderr: String, + duration: std::time::Duration, + }, } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dca821f255..7a8e675718 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -47,6 +47,8 @@ use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use std::time::Duration; +use std::time::Instant; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -86,6 +88,8 @@ use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; use codex_protocol::mcp_protocol::ConversationId; +use std::process::Stdio; +use tokio::process::Command as TokioCommand; // Track information about an in-flight exec command. struct RunningCommand { @@ -957,6 +961,18 @@ impl ChatWidget { let UserMessage { text, image_paths } = user_message; let mut items: Vec = Vec::new(); + // Bang command passthrough: if the first non-whitespace char is '!', run locally via bash. + if text.trim_start().starts_with('!') { + let mut cmd = text.trim_start(); + // Drop the leading '!' + cmd = &cmd[1..]; + let cmd = cmd.trim_start().to_string(); + if !cmd.is_empty() { + self.run_local_bash_command(cmd); + } + return; + } + if !text.is_empty() { items.push(InputItem::Text { text: text.clone() }); } @@ -990,6 +1006,123 @@ impl ChatWidget { } } + fn run_local_bash_command(&mut self, cmd_text: String) { + // Prepare a call id and parsed command for UI. + let call_id = { + let mut rng = rand::rng(); + format!("local-{}", rng.random::()) + }; + let command: Vec = vec!["bash".into(), "-lc".into(), cmd_text.clone()]; + let parsed_cmd: Vec = codex_core::parse_command::parse_command(&command) + .into_iter() + .map(Into::into) + .collect(); + + // Insert/append an active ExecCell to show the running command promptly. + if let Some(exec) = &self.active_exec_cell { + if let Some(new_exec) = + exec.with_added_call(call_id.clone(), command.clone(), parsed_cmd.clone()) + { + self.active_exec_cell = Some(new_exec); + } else { + self.flush_active_exec_cell(); + self.active_exec_cell = Some(history_cell::new_active_exec_command( + call_id.clone(), + command.clone(), + parsed_cmd.clone(), + )); + } + } else { + self.active_exec_cell = Some(history_cell::new_active_exec_command( + call_id.clone(), + command.clone(), + parsed_cmd.clone(), + )); + } + self.request_redraw(); + + // Spawn the command asynchronously. + let tx = self.app_event_tx.clone(); + let cwd = self.config.cwd.clone(); + let command_for_event = command.clone(); + let parsed_for_event = parsed_cmd.clone(); + tokio::spawn(async move { + let start = Instant::now(); + let output = TokioCommand::new("bash") + .arg("-lc") + .arg(cmd_text) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(cwd) + .output() + .await; + + match output { + Ok(out) => { + let exit_code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let duration = start.elapsed(); + tx.send(crate::app_event::AppEvent::LocalExecResult { + call_id, + command: command_for_event, + parsed_cmd: parsed_for_event, + exit_code, + stdout, + stderr, + duration, + }); + } + Err(e) => { + let duration = start.elapsed(); + tx.send(crate::app_event::AppEvent::LocalExecResult { + call_id, + command: command_for_event, + parsed_cmd: parsed_for_event, + exit_code: 127, + stdout: String::new(), + stderr: format!("failed to run command: {e}"), + duration, + }); + } + } + }); + } + + pub(crate) fn on_local_exec_result( + &mut self, + call_id: String, + command: Vec, + parsed_cmd: Vec, + exit_code: i32, + stdout: String, + stderr: String, + duration: Duration, + ) { + // Complete the matching active exec call if present; otherwise, insert a completed cell. + let output = CommandOutput { + exit_code, + stdout, + stderr, + formatted_output: String::new(), + }; + + if let Some(exec) = &mut self.active_exec_cell { + exec.complete_call(&call_id, output, duration); + // If the cell is fully complete and not an exploring group, flush it. + if exec.should_flush() { + self.flush_active_exec_cell(); + } + self.request_redraw(); + } else { + // No active cell (edge race): create a one-off completed cell. + let mut cell = history_cell::new_active_exec_command(call_id, command, parsed_cmd); + cell.complete_call("", output, duration); + self.add_boxed_history(Box::new(cell)); + self.request_redraw(); + } + } + /// Replay a subset of initial events into the UI to seed the transcript when /// resuming an existing session. This approximates the live event flow and /// is intentionally conservative: only safe-to-replay items are rendered to