From 89591e42460b0455a0fe73147028092f7942f0c8 Mon Sep 17 00:00:00 2001 From: Abhishek Bhardwaj Date: Wed, 29 Oct 2025 00:31:20 -0700 Subject: [PATCH] feature: Add "!cmd" user shell execution (#2471) feature: Add "!cmd" user shell execution This change lets users run local shell commands directly from the TUI by prefixing their input with ! (e.g. !ls). Output is truncated to keep the exec cell usable, and Ctrl-C cleanly interrupts long-running commands (e.g. !sleep 10000). **Summary of changes** - Route Op::RunUserShellCommand through a dedicated UserShellCommandTask (core/src/tasks/user_shell.rs), keeping the task logic out of codex.rs. - Reuse the existing tool router: the task constructs a ToolCall for the local_shell tool and relies on ShellHandler, so no manual MCP tool lookup is required. - Emit exec lifecycle events (ExecCommandBegin/ExecCommandEnd) so the TUI can show command metadata, live output, and exit status. **End-to-end flow** **TUI handling** 1. ChatWidget::submit_user_message (TUI) intercepts messages starting with !. 2. Non-empty commands dispatch Op::RunUserShellCommand { command }; empty commands surface a help hint. 3. No UserInput items are created, so nothing is enqueued for the model. **Core submission loop** 4. The submission loop routes the op to handlers::run_user_shell_command (core/src/codex.rs). 5. A fresh TurnContext is created and Session::spawn_user_shell_command enqueues UserShellCommandTask. **Task execution** 6. UserShellCommandTask::run emits TaskStartedEvent, formats the command, and prepares a ToolCall targeting local_shell. 7. ToolCallRuntime::handle_tool_call dispatches to ShellHandler. **Shell tool runtime** 8. ShellHandler::run_exec_like launches the process via the unified exec runtime, honoring sandbox and shell policies, and emits ExecCommandBegin/End. 9. Stdout/stderr are captured for the UI, but the task does not turn the resulting ToolOutput into a model response. **Completion** 10. After ExecCommandEnd, the task finishes without an assistant message; the session marks it complete and the exec cell displays the final output. **Conversation context** - The command and its output never enter the conversation history or the model prompt; the flow is local-only. - Only exec/task events are emitted for UI rendering. **Demo video** https://github.com/user-attachments/assets/fcd114b0-4304-4448-a367-a04c43e0b996 --- codex-rs/core/src/codex.rs | 28 ++++ codex-rs/core/src/tasks/mod.rs | 2 + codex-rs/core/src/tasks/user_shell.rs | 112 ++++++++++++++ codex-rs/core/src/tools/context.rs | 3 + codex-rs/core/src/tools/events.rs | 30 +++- codex-rs/core/src/tools/handlers/shell.rs | 9 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/user_shell_cmd.rs | 140 ++++++++++++++++++ .../tests/event_processor_with_json_output.rs | 2 + codex-rs/protocol/src/protocol.rs | 14 ++ codex-rs/tui/src/chatwidget.rs | 31 +++- codex-rs/tui/src/chatwidget/tests.rs | 3 + codex-rs/tui/src/exec_cell/model.rs | 3 + codex-rs/tui/src/exec_cell/render.rs | 39 ++++- codex-rs/tui/src/history_cell.rs | 22 ++- codex-rs/tui/src/pager_overlay.rs | 1 + 16 files changed, 419 insertions(+), 21 deletions(-) create mode 100644 codex-rs/core/src/tasks/user_shell.rs create mode 100644 codex-rs/core/tests/suite/user_shell_cmd.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7c8151d2..d32c651f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1287,6 +1287,15 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::Compact => { handlers::compact(&sess, sub.id.clone()).await; } + Op::RunUserShellCommand { command } => { + handlers::run_user_shell_command( + &sess, + sub.id.clone(), + command, + &mut previous_context, + ) + .await; + } Op::Shutdown => { if handlers::shutdown(&sess, sub.id.clone()).await { break; @@ -1313,6 +1322,7 @@ mod handlers { use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::UndoTask; + use crate::tasks::UserShellCommandTask; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -1388,6 +1398,24 @@ mod handlers { } } + pub async fn run_user_shell_command( + sess: &Arc, + sub_id: String, + command: String, + previous_context: &mut Option>, + ) { + let turn_context = sess + .new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default()) + .await; + sess.spawn_task( + Arc::clone(&turn_context), + Vec::new(), + UserShellCommandTask::new(command), + ) + .await; + *previous_context = Some(turn_context); + } + pub async fn exec_approval(sess: &Arc, id: String, decision: ReviewDecision) { match decision { ReviewDecision::Abort => { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ae22abe8..c81fa2c0 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -3,6 +3,7 @@ mod ghost_snapshot; mod regular; mod review; mod undo; +mod user_shell; use std::sync::Arc; use std::time::Duration; @@ -31,6 +32,7 @@ pub(crate) use ghost_snapshot::GhostSnapshotTask; pub(crate) use regular::RegularTask; pub(crate) use review::ReviewTask; pub(crate) use undo::UndoTask; +pub(crate) use user_shell::UserShellCommandTask; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs new file mode 100644 index 00000000..0e57e1b7 --- /dev/null +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use codex_protocol::models::ShellToolCallParams; +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::protocol::EventMsg; +use crate::protocol::TaskStartedEvent; +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 super::SessionTask; +use super::SessionTaskContext; + +const USER_SHELL_TOOL_NAME: &str = "local_shell"; + +#[derive(Clone)] +pub(crate) struct UserShellCommandTask { + command: String, +} + +impl UserShellCommandTask { + pub(crate) fn new(command: String) -> Self { + Self { command } + } +} + +#[async_trait] +impl SessionTask for UserShellCommandTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + async fn run( + self: Arc, + session: Arc, + turn_context: Arc, + _input: Vec, + cancellation_token: CancellationToken, + ) -> Option { + let event = EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: turn_context.client.get_model_context_window(), + }); + let session = session.clone_session(); + session.send_event(turn_context.as_ref(), event).await; + + // Execute the user's script under their default shell when known; this + // allows commands that use shell features (pipes, &&, redirects, etc.). + // We do not source rc files or otherwise reformat the script. + let shell_invocation = match session.user_shell() { + crate::shell::Shell::Zsh(zsh) => vec![ + zsh.shell_path.clone(), + "-lc".to_string(), + self.command.clone(), + ], + crate::shell::Shell::Bash(bash) => vec![ + bash.shell_path.clone(), + "-lc".to_string(), + self.command.clone(), + ], + crate::shell::Shell::PowerShell(ps) => vec![ + ps.exe.clone(), + "-NoProfile".to_string(), + "-Command".to_string(), + self.command.clone(), + ], + crate::shell::Shell::Unknown => { + shlex::split(&self.command).unwrap_or_else(|| vec![self.command.clone()]) + } + }; + + let params = ShellToolCallParams { + command: shell_invocation, + workdir: None, + timeout_ms: None, + with_escalated_permissions: None, + justification: 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 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), + ); + + if let Err(err) = runtime + .handle_tool_call(tool_call, cancellation_token) + .await + { + error!("user shell command failed: {err:?}"); + } + None + } +} diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index d2e47f92..029bacaa 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -255,6 +255,9 @@ pub(crate) struct ExecCommandContext { pub(crate) apply_patch: Option, pub(crate) tool_name: String, pub(crate) otel_event_manager: OtelEventManager, + // TODO(abhisek-oai): Find a better way to track this. + // https://github.com/openai/codex/pull/2471/files#r2470352242 + pub(crate) is_user_shell_command: bool, } #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index cb267c89..c6ef1ce4 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -56,7 +56,12 @@ pub(crate) enum ToolEventFailure { Message(String), } -pub(crate) async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, command: &[String], cwd: &Path) { +pub(crate) async fn emit_exec_command_begin( + ctx: ToolEventCtx<'_>, + command: &[String], + cwd: &Path, + is_user_shell_command: bool, +) { ctx.session .send_event( ctx.turn, @@ -65,6 +70,7 @@ pub(crate) async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, command: &[St command: command.to_vec(), cwd: cwd.to_path_buf(), parsed_cmd: parse_command(command), + is_user_shell_command, }), ) .await; @@ -74,6 +80,7 @@ pub(crate) enum ToolEmitter { Shell { command: Vec, cwd: PathBuf, + is_user_shell_command: bool, }, ApplyPatch { changes: HashMap, @@ -89,8 +96,12 @@ pub(crate) enum ToolEmitter { } impl ToolEmitter { - pub fn shell(command: Vec, cwd: PathBuf) -> Self { - Self::Shell { command, cwd } + pub fn shell(command: Vec, cwd: PathBuf, is_user_shell_command: bool) -> Self { + Self::Shell { + command, + cwd, + is_user_shell_command, + } } pub fn apply_patch(changes: HashMap, auto_approved: bool) -> Self { @@ -110,8 +121,15 @@ impl ToolEmitter { pub async fn emit(&self, ctx: ToolEventCtx<'_>, stage: ToolEventStage) { match (self, stage) { - (Self::Shell { command, cwd }, ToolEventStage::Begin) => { - emit_exec_command_begin(ctx, command, cwd.as_path()).await; + ( + Self::Shell { + command, + cwd, + is_user_shell_command, + }, + ToolEventStage::Begin, + ) => { + emit_exec_command_begin(ctx, command, cwd.as_path(), *is_user_shell_command).await; } (Self::Shell { .. }, ToolEventStage::Success(output)) => { emit_exec_end( @@ -200,7 +218,7 @@ impl ToolEmitter { emit_patch_end(ctx, String::new(), (*message).to_string(), false).await; } (Self::UnifiedExec { command, cwd, .. }, ToolEventStage::Begin) => { - emit_exec_command_begin(ctx, &[command.to_string()], cwd.as_path()).await; + emit_exec_command_begin(ctx, &[command.to_string()], cwd.as_path(), false).await; } (Self::UnifiedExec { .. }, ToolEventStage::Success(output)) => { emit_exec_end( diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 76650992..b97242a9 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -78,6 +78,7 @@ impl ToolHandler for ShellHandler { turn, tracker, call_id, + false, ) .await } @@ -90,6 +91,7 @@ impl ToolHandler for ShellHandler { turn, tracker, call_id, + true, ) .await } @@ -108,6 +110,7 @@ impl ShellHandler { turn: Arc, tracker: crate::tools::context::SharedTurnDiffTracker, call_id: String, + is_user_shell_command: bool, ) -> Result { // Approval policy guard for explicit escalation in non-OnRequest modes. if exec_params.with_escalated_permissions.unwrap_or(false) @@ -201,7 +204,11 @@ impl ShellHandler { } // Regular shell execution path. - let emitter = ToolEmitter::shell(exec_params.command.clone(), exec_params.cwd.clone()); + let emitter = ToolEmitter::shell( + exec_params.command.clone(), + exec_params.cwd.clone(), + is_user_shell_command, + ); let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None); emitter.begin(event_ctx).await; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 300ca146..0dcd8256 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -36,4 +36,5 @@ mod tools; mod truncation; mod unified_exec; mod user_notification; +mod user_shell_cmd; mod view_image; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs new file mode 100644 index 00000000..0832d7e6 --- /dev/null +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -0,0 +1,140 @@ +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 core_test_support::load_default_config_for_test; +use core_test_support::wait_for_event; +use std::path::PathBuf; +use std::process::Command; +use std::process::Stdio; +use tempfile::TempDir; + +fn detect_python_executable() -> Option { + let candidates = ["python3", "python"]; + candidates.iter().find_map(|candidate| { + Command::new(candidate) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok() + .and_then(|status| status.success().then(|| (*candidate).to_string())) + }) +} + +#[tokio::test] +async fn user_shell_cmd_ls_and_cat_in_temp_dir() { + let Some(python) = detect_python_executable() else { + eprintln!("skipping test: python3 not found in PATH"); + return; + }; + + // Create a temporary working directory with a known file. + let cwd = TempDir::new().unwrap(); + let file_name = "hello.txt"; + let file_path: PathBuf = cwd.path().join(file_name); + let contents = "hello from bang test\n"; + tokio::fs::write(&file_path, contents) + .await + .expect("write temp file"); + + // Load config and pin cwd to the temp dir so ls/cat operate there. + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.cwd = cwd.path().to_path_buf(); + + let conversation_manager = + ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let NewConversation { + conversation: codex, + .. + } = conversation_manager + .new_conversation(config) + .await + .expect("create new conversation"); + + // 1) python should list the file + let list_cmd = format!( + "{python} -c \"import pathlib; print('\\n'.join(sorted(p.name for p in pathlib.Path('.').iterdir())))\"" + ); + codex + .submit(Op::RunUserShellCommand { command: list_cmd }) + .await + .unwrap(); + let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await; + let EventMsg::ExecCommandEnd(ExecCommandEndEvent { + stdout, exit_code, .. + }) = msg + else { + unreachable!() + }; + assert_eq!(exit_code, 0); + assert!( + stdout.contains(file_name), + "ls output should include {file_name}, got: {stdout:?}" + ); + + // 2) python should print the file contents verbatim + let cat_cmd = format!( + "{python} -c \"import pathlib; print(pathlib.Path('{file_name}').read_text(), end='')\"" + ); + codex + .submit(Op::RunUserShellCommand { command: cat_cmd }) + .await + .unwrap(); + let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await; + let EventMsg::ExecCommandEnd(ExecCommandEndEvent { + mut stdout, + exit_code, + .. + }) = msg + else { + unreachable!() + }; + assert_eq!(exit_code, 0); + if cfg!(windows) { + // Windows' Python writes CRLF line endings; normalize so the assertion remains portable. + stdout = stdout.replace("\r\n", "\n"); + } + assert_eq!(stdout, contents); +} + +#[tokio::test] +async fn user_shell_cmd_can_be_interrupted() { + let Some(python) = detect_python_executable() else { + eprintln!("skipping test: python3 not found in PATH"); + return; + }; + // Set up isolated config and conversation. + let codex_home = TempDir::new().unwrap(); + let config = load_default_config_for_test(&codex_home); + let conversation_manager = + ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let NewConversation { + conversation: codex, + .. + } = conversation_manager + .new_conversation(config) + .await + .expect("create new conversation"); + + // Start a long-running command and then interrupt it. + let sleep_cmd = format!("{python} -c \"import time; time.sleep(5)\""); + codex + .submit(Op::RunUserShellCommand { command: sleep_cmd }) + .await + .unwrap(); + + // Wait until it has started (ExecCommandBegin), then interrupt. + let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; + codex.submit(Op::Interrupt).await.unwrap(); + + // Expect a TurnAborted(Interrupted) notification. + let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; + let EventMsg::TurnAborted(ev) = msg else { + unreachable!() + }; + assert_eq!(ev.reason, TurnAbortReason::Interrupted); +} 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 a995b463..f4797f70 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -506,6 +506,7 @@ fn exec_command_end_success_produces_completed_command_item() { command: vec!["bash".to_string(), "-lc".to_string(), "echo hi".to_string()], cwd: std::env::current_dir().unwrap(), parsed_cmd: Vec::new(), + is_user_shell_command: false, }), ); let out_begin = ep.collect_thread_events(&begin); @@ -566,6 +567,7 @@ fn exec_command_end_failure_produces_failed_command_item() { command: vec!["sh".to_string(), "-c".to_string(), "exit 1".to_string()], cwd: std::env::current_dir().unwrap(), parsed_cmd: Vec::new(), + is_user_shell_command: false, }), ); assert_eq!( diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a7f5241b..e686202b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -186,6 +186,16 @@ pub enum Op { /// Request to shut down codex instance. Shutdown, + + /// Execute a user-initiated one-off shell command (triggered by "!cmd"). + /// + /// The command string is executed using the user's default shell and may + /// include shell syntax (pipes, redirects, etc.). Output is streamed via + /// `ExecCommand*` events and the UI regains control upon `TaskComplete`. + RunUserShellCommand { + /// The raw command string after '!' + command: String, + }, } /// Determines the conditions under which the user is consulted to approve @@ -1086,6 +1096,10 @@ pub struct ExecCommandBeginEvent { /// The command's working directory if not the default cwd for the agent. pub cwd: PathBuf, pub parsed_cmd: Vec, + /// True when this exec was initiated directly by the user (e.g. bang command), + /// not by the agent/model. Defaults to false for backwards compatibility. + #[serde(default)] + pub is_user_shell_command: bool, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e1255c84..30acf2a0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -120,10 +120,13 @@ use codex_file_search::FileMatch; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; +const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; +const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; // Track information about an in-flight exec command. struct RunningCommand { command: Vec, parsed_cmd: Vec, + is_user_shell_command: bool, } const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; @@ -771,9 +774,9 @@ impl ChatWidget { pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { let running = self.running_commands.remove(&ev.call_id); - let (command, parsed) = match running { - Some(rc) => (rc.command, rc.parsed_cmd), - None => (vec![ev.call_id.clone()], Vec::new()), + 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), }; let needs_new = self @@ -787,6 +790,7 @@ impl ChatWidget { ev.call_id.clone(), command, parsed, + is_user_shell_command, ))); } @@ -865,6 +869,7 @@ impl ChatWidget { RunningCommand { command: ev.command.clone(), parsed_cmd: ev.parsed_cmd.clone(), + is_user_shell_command: ev.is_user_shell_command, }, ); if let Some(cell) = self @@ -875,6 +880,7 @@ impl ChatWidget { ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd.clone(), + ev.is_user_shell_command, ) { *cell = new_exec; @@ -885,6 +891,7 @@ impl ChatWidget { ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd, + ev.is_user_shell_command, ))); } @@ -1347,6 +1354,24 @@ impl ChatWidget { let mut items: Vec = Vec::new(); + // Special-case: "!cmd" executes a local shell command instead of sending to the model. + if let Some(stripped) = text.strip_prefix('!') { + let cmd = stripped.trim(); + if cmd.is_empty() { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + USER_SHELL_COMMAND_HELP_TITLE.to_string(), + Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), + ), + ))); + return; + } + self.submit_op(Op::RunUserShellCommand { + command: cmd.to_string(), + }); + return; + } + if !text.is_empty() { items.push(UserInput::Text { text: text.clone() }); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3bfb29f8..eb53f90f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -531,6 +531,7 @@ fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) { command, cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), parsed_cmd, + is_user_shell_command: false, }), }); } @@ -1509,6 +1510,7 @@ async fn binary_size_transcript_snapshot() { command: e.command, cwd: e.cwd, parsed_cmd, + is_user_shell_command: false, }), } } @@ -2558,6 +2560,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { path: "diff_render.rs".into(), }, ], + is_user_shell_command: false, }), }); chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index cf9acc54..1d6b544e 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -18,6 +18,7 @@ pub(crate) struct ExecCall { pub(crate) command: Vec, pub(crate) parsed: Vec, pub(crate) output: Option, + pub(crate) is_user_shell_command: bool, pub(crate) start_time: Option, pub(crate) duration: Option, } @@ -37,12 +38,14 @@ impl ExecCell { call_id: String, command: Vec, parsed: Vec, + is_user_shell_command: bool, ) -> Option { let call = ExecCall { call_id, command, parsed, output: None, + is_user_shell_command, start_time: Some(Instant::now()), duration: None, }; diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index bc336b98..4f21f96e 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -26,8 +26,11 @@ use textwrap::WordSplitter; use unicode_width::UnicodeWidthStr; pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; +const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50; pub(crate) struct OutputLinesParams { + pub(crate) line_limit: usize, + pub(crate) only_err: bool, pub(crate) include_angle_pipe: bool, pub(crate) include_prefix: bool, } @@ -36,12 +39,14 @@ pub(crate) fn new_active_exec_command( call_id: String, command: Vec, parsed: Vec, + is_user_shell_command: bool, ) -> ExecCell { ExecCell::new(ExecCall { call_id, command, parsed, output: None, + is_user_shell_command, start_time: Some(Instant::now()), duration: None, }) @@ -58,12 +63,20 @@ pub(crate) fn output_lines( params: OutputLinesParams, ) -> OutputLines { let OutputLinesParams { + line_limit, + only_err, include_angle_pipe, include_prefix, } = params; let CommandOutput { aggregated_output, .. } = match output { + Some(output) if only_err && output.exit_code == 0 => { + return OutputLines { + lines: Vec::new(), + omitted: None, + }; + } Some(output) => output, None => { return OutputLines { @@ -76,11 +89,9 @@ pub(crate) fn output_lines( let src = aggregated_output; let lines: Vec<&str> = src.lines().collect(); let total = lines.len(); - let limit = TOOL_CALL_MAX_LINES; - let mut out: Vec> = Vec::new(); - let head_end = total.min(limit); + let head_end = total.min(line_limit); for (i, raw) in lines[..head_end].iter().enumerate() { let mut line = ansi_escape_line(raw); let prefix = if !include_prefix { @@ -97,19 +108,19 @@ pub(crate) fn output_lines( out.push(line); } - let show_ellipsis = total > 2 * limit; + let show_ellipsis = total > 2 * line_limit; let omitted = if show_ellipsis { - Some(total - 2 * limit) + Some(total - 2 * line_limit) } else { None }; if show_ellipsis { - let omitted = total - 2 * limit; + let omitted = total - 2 * line_limit; out.push(format!("… +{omitted} lines").into()); } let tail_start = if show_ellipsis { - total - limit + total - line_limit } else { head_end }; @@ -384,13 +395,25 @@ impl ExecCell { } if let Some(output) = call.output.as_ref() { + let line_limit = if call.is_user_shell_command { + USER_SHELL_TOOL_CALL_MAX_LINES + } else { + TOOL_CALL_MAX_LINES + }; let raw_output = output_lines( Some(output), OutputLinesParams { + line_limit, + only_err: false, include_angle_pipe: false, include_prefix: false, }, ); + let display_limit = if call.is_user_shell_command { + USER_SHELL_TOOL_CALL_MAX_LINES + } else { + layout.output_max_lines + }; if raw_output.lines.is_empty() { lines.extend(prefix_lines( @@ -401,7 +424,7 @@ impl ExecCell { } else { let trimmed_output = Self::truncate_lines_middle( &raw_output.lines, - layout.output_max_lines, + display_limit, raw_output.omitted, ); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 66d4899b..0cbaf120 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -876,18 +876,20 @@ impl HistoryCell for McpToolCallCell { } let mut detail_lines: Vec> = Vec::new(); + // Reserve four columns for the tree prefix (" └ "/" ") and ensure the wrapper still has at least one cell to work with. + let detail_wrap_width = (width as usize).saturating_sub(4).max(1); if let Some(result) = &self.result { match result { Ok(mcp_types::CallToolResult { content, .. }) => { if !content.is_empty() { for block in content { - let text = Self::render_content_block(block, width as usize); + let text = Self::render_content_block(block, detail_wrap_width); for segment in text.split('\n') { let line = Line::from(segment.to_string().dim()); let wrapped = word_wrap_line( &line, - RtOptions::new((width as usize).saturating_sub(4)) + RtOptions::new(detail_wrap_width) .initial_indent("".into()) .subsequent_indent(" ".into()), ); @@ -905,7 +907,7 @@ impl HistoryCell for McpToolCallCell { let err_line = Line::from(err_text.dim()); let wrapped = word_wrap_line( &err_line, - RtOptions::new((width as usize).saturating_sub(4)) + RtOptions::new(detail_wrap_width) .initial_indent("".into()) .subsequent_indent(" ".into()), ); @@ -1296,6 +1298,8 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { aggregated_output: stderr, }), OutputLinesParams { + line_limit: TOOL_CALL_MAX_LINES, + only_err: true, include_angle_pipe: true, include_prefix: true, }, @@ -1823,6 +1827,7 @@ mod tests { }, ], output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); @@ -1845,6 +1850,7 @@ mod tests { cmd: "rg shimmer_spans".into(), }], output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); @@ -1860,6 +1866,7 @@ mod tests { cmd: "cat shimmer.rs".into(), path: "shimmer.rs".into(), }], + false, ) .unwrap(); cell.complete_call("c2", CommandOutput::default(), Duration::from_millis(1)); @@ -1873,6 +1880,7 @@ mod tests { cmd: "cat status_indicator_widget.rs".into(), path: "status_indicator_widget.rs".into(), }], + false, ) .unwrap(); cell.complete_call("c3", CommandOutput::default(), Duration::from_millis(1)); @@ -1905,6 +1913,7 @@ mod tests { }, ], output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); @@ -1924,6 +1933,7 @@ mod tests { command: vec!["bash".into(), "-lc".into(), cmd], parsed: Vec::new(), output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); @@ -1945,6 +1955,7 @@ mod tests { command: vec!["echo".into(), "ok".into()], parsed: Vec::new(), output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); @@ -1964,6 +1975,7 @@ mod tests { command: vec!["bash".into(), "-lc".into(), long], parsed: Vec::new(), output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); @@ -1982,6 +1994,7 @@ mod tests { command: vec!["bash".into(), "-lc".into(), cmd], parsed: Vec::new(), output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); @@ -2001,6 +2014,7 @@ mod tests { command: vec!["bash".into(), "-lc".into(), cmd], parsed: Vec::new(), output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); @@ -2020,6 +2034,7 @@ mod tests { command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()], parsed: Vec::new(), output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); @@ -2065,6 +2080,7 @@ mod tests { command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()], parsed: Vec::new(), output: None, + is_user_shell_command: false, start_time: Some(Instant::now()), duration: None, }); diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index b02e02ef..82f43d84 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -719,6 +719,7 @@ mod tests { "exec-1".into(), vec!["bash".into(), "-lc".into(), "ls".into()], vec![ParsedCommand::Unknown { cmd: "ls".into() }], + false, ); exec_cell.complete_call( "exec-1",