From 41e490df30a32f46e00543cc0be1d858682ba59e Mon Sep 17 00:00:00 2001 From: Nathan Liebrecht Date: Fri, 10 Apr 2026 12:16:48 -0700 Subject: [PATCH] show remote host in exec tui cells --- .../src/protocol/thread_history.rs | 4 + codex-rs/core/src/tasks/user_shell.rs | 4 + codex-rs/core/src/tools/events.rs | 44 +++++------ .../core/src/tools/handlers/unified_exec.rs | 34 +++++++- .../core/src/unified_exec/async_watcher.rs | 7 ++ .../core/src/unified_exec/process_manager.rs | 6 ++ codex-rs/exec-server/src/environment.rs | 15 +++- codex-rs/protocol/src/protocol.rs | 8 ++ codex-rs/tui/src/app/app_server_adapter.rs | 2 + codex-rs/tui/src/chatwidget.rs | 20 ++++- .../tui/src/chatwidget/tests/exec_flow.rs | 77 +++++++++++++++++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 4 + .../src/chatwidget/tests/status_and_layout.rs | 2 + codex-rs/tui/src/exec_cell/model.rs | 11 ++- codex-rs/tui/src/exec_cell/render.rs | 31 ++++++-- codex-rs/tui/src/history_cell.rs | 12 +++ codex-rs/tui/src/pager_overlay.rs | 1 + 17 files changed, 239 insertions(+), 43 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index b005657968..80ad17ba77 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1790,6 +1790,7 @@ mod tests { cmd: "echo hello world".into(), }], source: ExecCommandSource::Agent, + host_id: None, interaction_input: None, stdout: String::new(), stderr: String::new(), @@ -2007,6 +2008,7 @@ mod tests { cwd: PathBuf::from("/tmp"), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }], source: ExecCommandSource::Agent, + host_id: None, interaction_input: None, stdout: String::new(), stderr: "exec command rejected by user".into(), @@ -2247,6 +2249,7 @@ mod tests { cmd: "echo done".into(), }], source: ExecCommandSource::Agent, + host_id: None, interaction_input: None, stdout: "done\n".into(), stderr: String::new(), @@ -2336,6 +2339,7 @@ mod tests { cmd: "echo done".into(), }], source: ExecCommandSource::Agent, + host_id: None, interaction_input: None, stdout: "done\n".into(), stderr: String::new(), diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index e0a7703879..c2eb4b6cf9 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -152,6 +152,7 @@ pub(crate) async fn execute_user_shell_command( cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, + host_id: None, interaction_input: None, }), ) @@ -220,6 +221,7 @@ pub(crate) async fn execute_user_shell_command( cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, + host_id: None, interaction_input: None, stdout: String::new(), stderr: aborted_message.clone(), @@ -244,6 +246,7 @@ pub(crate) async fn execute_user_shell_command( cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, + host_id: None, interaction_input: None, stdout: output.stdout.text.clone(), stderr: output.stderr.text.clone(), @@ -288,6 +291,7 @@ pub(crate) async fn execute_user_shell_command( cwd: cwd.to_path_buf(), parsed_cmd, source: ExecCommandSource::UserShell, + host_id: None, interaction_input: None, stdout: exec_output.stdout.text.clone(), stderr: exec_output.stderr.text.clone(), diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 58438cf563..28c6bb20c4 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -61,27 +61,20 @@ pub(crate) enum ToolEventFailure { Rejected(String), } -pub(crate) async fn emit_exec_command_begin( - ctx: ToolEventCtx<'_>, - command: &[String], - cwd: &Path, - parsed_cmd: &[ParsedCommand], - source: ExecCommandSource, - interaction_input: Option, - process_id: Option<&str>, -) { +async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommandInput<'_>) { ctx.session .send_event( ctx.turn, EventMsg::ExecCommandBegin(ExecCommandBeginEvent { call_id: ctx.call_id.to_string(), - process_id: process_id.map(str::to_owned), + process_id: exec_input.process_id.map(str::to_owned), turn_id: ctx.turn.sub_id.clone(), - command: command.to_vec(), - cwd: cwd.to_path_buf(), - parsed_cmd: parsed_cmd.to_vec(), - source, - interaction_input, + command: exec_input.command.to_vec(), + cwd: exec_input.cwd.to_path_buf(), + parsed_cmd: exec_input.parsed_cmd.to_vec(), + source: exec_input.source, + host_id: exec_input.host_id.map(str::to_owned), + interaction_input: exec_input.interaction_input.map(str::to_owned), }), ) .await; @@ -105,6 +98,7 @@ pub(crate) enum ToolEmitter { source: ExecCommandSource, parsed_cmd: Vec, process_id: Option, + host_id: Option, }, } @@ -137,6 +131,7 @@ impl ToolEmitter { cwd: PathBuf, source: ExecCommandSource, process_id: Option, + host_id: Option, ) -> Self { let parsed_cmd = parse_command(command); Self::UnifiedExec { @@ -145,6 +140,7 @@ impl ToolEmitter { source, parsed_cmd, process_id, + host_id, } } @@ -169,6 +165,7 @@ impl ToolEmitter { *source, /*interaction_input*/ None, /*process_id*/ None, + /*host_id*/ None, ), stage, ) @@ -266,6 +263,7 @@ impl ToolEmitter { source, parsed_cmd, process_id, + host_id, }, stage, ) => { @@ -278,6 +276,7 @@ impl ToolEmitter { *source, /*interaction_input*/ None, process_id.as_deref(), + host_id.as_deref(), ), stage, ) @@ -370,6 +369,7 @@ struct ExecCommandInput<'a> { source: ExecCommandSource, interaction_input: Option<&'a str>, process_id: Option<&'a str>, + host_id: Option<&'a str>, } impl<'a> ExecCommandInput<'a> { @@ -380,6 +380,7 @@ impl<'a> ExecCommandInput<'a> { source: ExecCommandSource, interaction_input: Option<&'a str>, process_id: Option<&'a str>, + host_id: Option<&'a str>, ) -> Self { Self { command, @@ -388,6 +389,7 @@ impl<'a> ExecCommandInput<'a> { source, interaction_input, process_id, + host_id, } } } @@ -409,16 +411,7 @@ async fn emit_exec_stage( ) { match stage { ToolEventStage::Begin => { - emit_exec_command_begin( - ctx, - exec_input.command, - exec_input.cwd, - exec_input.parsed_cmd, - exec_input.source, - exec_input.interaction_input.map(str::to_owned), - exec_input.process_id, - ) - .await; + emit_exec_command_begin(ctx, &exec_input).await; } ToolEventStage::Success(output) | ToolEventStage::Failure(ToolEventFailure::Output(output)) => { @@ -482,6 +475,7 @@ async fn emit_exec_end( cwd: exec_input.cwd.to_path_buf(), parsed_cmd: exec_input.parsed_cmd.to_vec(), source: exec_input.source, + host_id: exec_input.host_id.map(str::to_owned), interaction_input: exec_input.interaction_input.map(str::to_owned), stdout: exec_result.stdout, stderr: exec_result.stderr, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 36abe7f9c0..2d7d5c2b34 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -185,12 +185,13 @@ impl ToolHandler for UnifiedExecHandler { "exec_command" => { let cwd = resolve_workdir_base_path(&arguments, &context.turn.cwd)?; let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; - let environment = resolve_exec_environment( + let resolved_environment = resolve_exec_environment( session.as_ref(), turn.as_ref(), args.host_id.as_deref(), ) .await?; + let environment = resolved_environment.environment; let default_host_cwd = if environment.is_remote() && args.workdir.is_none() { environment .default_cwd() @@ -232,7 +233,7 @@ impl ToolHandler for UnifiedExecHandler { ) .map_err(FunctionCallError::RespondToModel)?; let command_for_display = codex_shell_command::parse_command::shlex_join(&command); - let request_host_id = args.host_id.clone(); + let request_host_id = resolved_environment.host_id; let ExecCommandArgs { workdir, @@ -409,11 +410,16 @@ fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) ); } +struct ResolvedExecEnvironment { + environment: Arc, + host_id: Option, +} + async fn resolve_exec_environment( session: &crate::codex::Session, turn: &crate::codex::TurnContext, host_id: Option<&str>, -) -> Result, FunctionCallError> { +) -> Result { let environment = match host_id { Some(host_id) => session .services @@ -428,8 +434,28 @@ async fn resolve_exec_environment( None => turn.environment.clone(), }; - environment.ok_or_else(|| { + let environment = environment.ok_or_else(|| { FunctionCallError::RespondToModel("unified exec is unavailable in this session".to_string()) + })?; + let host_id = if environment.is_remote() { + Some(match host_id { + Some(host_id) => host_id.to_string(), + None => { + let default_host_id = session.services.environment_manager.default_host_id(); + if default_host_id == codex_exec_server::LOCAL_HOST_ID { + "remote".to_string() + } else { + default_host_id + } + } + }) + } else { + None + }; + + Ok(ResolvedExecEnvironment { + environment, + host_id, }) } diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index a5da040976..a15bf70766 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -110,6 +110,7 @@ pub(crate) fn spawn_exit_watcher( turn_ref: Arc, call_id: String, command: Vec, + host_id: Option, cwd: PathBuf, process_id: i32, transcript: Arc>, @@ -129,6 +130,7 @@ pub(crate) fn spawn_exit_watcher( turn_ref, call_id, command, + host_id, cwd, Some(process_id.to_string()), transcript, @@ -143,6 +145,7 @@ pub(crate) fn spawn_exit_watcher( turn_ref, call_id, command, + host_id, cwd, Some(process_id.to_string()), transcript, @@ -196,6 +199,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, + host_id: Option, cwd: PathBuf, process_id: Option, transcript: Arc>, @@ -223,6 +227,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec( cwd, ExecCommandSource::UnifiedExecStartup, process_id, + host_id, ); emitter .emit(event_ctx, ToolEventStage::Success(output)) @@ -235,6 +240,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, + host_id: Option, cwd: PathBuf, process_id: Option, transcript: Arc>, @@ -266,6 +272,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( cwd, ExecCommandSource::UnifiedExecStartup, process_id, + host_id, ); emitter .emit( diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index cf398dff04..7aa502b4b3 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -192,6 +192,7 @@ impl UnifiedExecProcessManager { cwd.to_path_buf(), ExecCommandSource::UnifiedExecStartup, Some(request.process_id.to_string()), + request.host_id.clone(), ); emitter.emit(event_ctx, ToolEventStage::Begin).await; @@ -208,6 +209,7 @@ impl UnifiedExecProcessManager { Arc::clone(&process), context, &request.command, + request.host_id.clone(), cwd.clone(), start, request.process_id, @@ -255,6 +257,7 @@ impl UnifiedExecProcessManager { Arc::clone(&context.turn), context.call_id.clone(), request.command.clone(), + request.host_id.clone(), cwd.to_path_buf(), Some(request.process_id.to_string()), Arc::clone(&transcript), @@ -298,6 +301,7 @@ impl UnifiedExecProcessManager { Arc::clone(&context.turn), context.call_id.clone(), request.command.clone(), + request.host_id.clone(), cwd.to_path_buf(), Some(process_id.to_string()), Arc::clone(&transcript), @@ -526,6 +530,7 @@ impl UnifiedExecProcessManager { process: Arc, context: &UnifiedExecContext, command: &[String], + host_id: Option, cwd: AbsolutePathBuf, started_at: Instant, process_id: i32, @@ -572,6 +577,7 @@ impl UnifiedExecProcessManager { Arc::clone(&context.turn), context.call_id.clone(), command.to_vec(), + host_id, cwd.to_path_buf(), process_id, transcript, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 1de905d657..e5409c87d1 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -414,7 +414,14 @@ impl Environment { match exec_server_url { Some(exec_server_url) => { - Self::create_remote(exec_server_url, None, None, None, "codex-environment").await + Self::create_remote( + exec_server_url, + /*default_cwd*/ None, + /*default_shell*/ None, + /*ssh_exec_server*/ None, + "codex-environment", + ) + .await } None => Ok(Self::default()), } @@ -426,9 +433,9 @@ impl Environment { HostConnection::ExecServerUrl { url } => { Self::create_remote( url.clone(), - None, - None, - None, + /*default_cwd*/ None, + /*default_shell*/ None, + /*ssh_exec_server*/ None, format!("codex-environment-{}", host_config.id), ) .await diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 44693033d8..613cddfa04 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3027,6 +3027,10 @@ pub struct ExecCommandBeginEvent { /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] pub source: ExecCommandSource, + /// Remote host id when the command is executed outside the local environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub host_id: Option, /// Raw input sent to a unified exec session (if this is an interaction event). #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -3051,6 +3055,10 @@ pub struct ExecCommandEndEvent { /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] pub source: ExecCommandSource, + /// Remote host id when the command is executed outside the local environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub host_id: Option, /// Raw input sent to a unified exec session (if this is an interaction event). #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 5df4c18e54..e1e62c6d62 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -922,6 +922,7 @@ fn command_execution_started_event(turn_id: &str, item: &ThreadItem) -> Option Option .map(codex_app_server_protocol::CommandAction::into_core) .collect(), source: source.to_core(), + host_id: None, interaction_input: None, stdout: String::new(), stderr: String::new(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 74b2032892..e4e8d93457 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -397,6 +397,7 @@ struct RunningCommand { command: Vec, parsed_cmd: Vec, source: ExecCommandSource, + host_id: Option, } struct UnifiedExecProcessSummary { @@ -4319,9 +4320,14 @@ impl ChatWidget { if self.suppressed_exec_calls.remove(&ev.call_id) { return; } - let (command, parsed, source) = match running { - Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), + let (command, parsed, source, host_id) = match running { + Some(rc) => (rc.command, rc.parsed_cmd, rc.source, rc.host_id), + None => ( + ev.command.clone(), + ev.parsed_cmd.clone(), + ev.source, + ev.host_id.clone(), + ), }; let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed); let is_unified_exec_interaction = @@ -4382,6 +4388,7 @@ impl ChatWidget { command, parsed, source, + host_id, ev.interaction_input.clone(), self.config.animations, ); @@ -4403,6 +4410,7 @@ impl ChatWidget { command, parsed, source, + host_id, ev.interaction_input.clone(), self.config.animations, ); @@ -4550,6 +4558,7 @@ impl ChatWidget { command: ev.command.clone(), parsed_cmd: parsed_cmd.clone(), source: ev.source, + host_id: ev.host_id.clone(), }, ); let is_wait_interaction = matches!(ev.source, ExecCommandSource::UnifiedExecInteraction) @@ -4583,6 +4592,7 @@ impl ChatWidget { ev.command.clone(), parsed_cmd.clone(), ev.source, + ev.host_id.clone(), interaction_input.clone(), ) { @@ -4596,6 +4606,7 @@ impl ChatWidget { ev.command.clone(), parsed_cmd, ev.source, + ev.host_id, interaction_input, self.config.animations, ))); @@ -6206,6 +6217,7 @@ impl ChatWidget { .map(codex_app_server_protocol::CommandAction::into_core) .collect(), source: source.to_core(), + host_id: None, interaction_input: None, }); } else { @@ -6221,6 +6233,7 @@ impl ChatWidget { .map(codex_app_server_protocol::CommandAction::into_core) .collect(), source: source.to_core(), + host_id: None, interaction_input: None, stdout: String::new(), stderr: String::new(), @@ -6792,6 +6805,7 @@ impl ChatWidget { .map(codex_app_server_protocol::CommandAction::into_core) .collect(), source: source.to_core(), + host_id: None, interaction_input: None, }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d271fc0076..d7fe16057f 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -346,6 +346,82 @@ async fn exec_history_cell_shows_working_then_failed() { assert!(blob.to_lowercase().contains("bloop"), "expected error text"); } +#[tokio::test] +async fn exec_history_cell_shows_remote_host_while_running() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "printf remote".to_string(), + ]; + let parsed_cmd = vec![ParsedCommand::Unknown { + cmd: "printf remote".to_string(), + }]; + chat.handle_codex_event(Event { + id: "call-remote-running".to_string(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-remote-running".to_string(), + process_id: Some("proc-remote".to_string()), + turn_id: "turn-1".to_string(), + command, + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd, + source: ExecCommandSource::Agent, + host_id: Some("remote-host".to_string()), + interaction_input: None, + }), + }); + + let blob = active_blob(&chat); + assert!( + blob.contains("• Running on remote-host"), + "expected remote host in running header: {blob:?}" + ); +} + +#[tokio::test] +async fn exec_history_cell_shows_remote_host_after_completion() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.on_task_started(); + + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo remote".to_string(), + ]; + let parsed_cmd = codex_shell_command::parse_command::parse_command(&command); + chat.handle_codex_event(Event { + id: "call-remote-complete".to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-remote-complete".to_string(), + process_id: Some("proc-remote".to_string()), + turn_id: "turn-1".to_string(), + command, + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd, + source: ExecCommandSource::UnifiedExecStartup, + host_id: Some("remote-host".to_string()), + interaction_input: None, + stdout: "remote\n".to_string(), + stderr: String::new(), + aggregated_output: "remote\n".to_string(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + formatted_output: "remote\n".to_string(), + status: CoreExecCommandStatus::Completed, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected finalized remote exec cell"); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("• Ran on remote-host echo remote"), + "expected remote host in completed header: {blob:?}" + ); +} + #[tokio::test] async fn exec_end_without_begin_uses_event_command() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -366,6 +442,7 @@ async fn exec_end_without_begin_uses_event_command() { cwd, parsed_cmd, source: ExecCommandSource::Agent, + host_id: None, interaction_input: None, stdout: "done".to_string(), stderr: String::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index f5241fac5d..110f5ac988 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -493,6 +493,7 @@ pub(super) fn begin_exec_with_source( cwd, parsed_cmd, source, + host_id: None, interaction_input, }; chat.handle_codex_event(Event { @@ -518,6 +519,7 @@ pub(super) fn begin_unified_exec_startup( cwd, parsed_cmd: Vec::new(), source: ExecCommandSource::UnifiedExecStartup, + host_id: None, interaction_input: None, }; chat.handle_codex_event(Event { @@ -632,6 +634,7 @@ pub(super) fn end_exec( cwd, parsed_cmd, source, + host_id, interaction_input, process_id, } = begin_event; @@ -645,6 +648,7 @@ pub(super) fn end_exec( cwd, parsed_cmd, source, + host_id, interaction_input, stdout: stdout.to_string(), stderr: stderr.to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 50de7064cc..973c9e6054 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1566,6 +1566,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::Agent, + host_id: None, interaction_input: None, }), }); @@ -1579,6 +1580,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { cwd, parsed_cmd, source: ExecCommandSource::Agent, + host_id: None, interaction_input: None, stdout: String::new(), stderr: String::new(), diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 878d42c711..3bf6504eb8 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -27,6 +27,7 @@ pub(crate) struct ExecCall { pub(crate) parsed: Vec, pub(crate) output: Option, pub(crate) source: ExecCommandSource, + pub(crate) host_id: Option, pub(crate) start_time: Option, pub(crate) duration: Option, pub(crate) interaction_input: Option, @@ -52,6 +53,7 @@ impl ExecCell { command: Vec, parsed: Vec, source: ExecCommandSource, + host_id: Option, interaction_input: Option, ) -> Option { let call = ExecCall { @@ -60,11 +62,18 @@ impl ExecCell { parsed, output: None, source, + host_id, start_time: Some(Instant::now()), duration: None, interaction_input, }; - if self.is_exploring_cell() && Self::is_exploring_call(&call) { + if self.is_exploring_cell() + && Self::is_exploring_call(&call) + && self + .calls + .iter() + .all(|existing| existing.host_id == call.host_id) + { Some(Self { calls: [self.calls.clone(), vec![call]].concat(), animations_enabled: self.animations_enabled, diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 423217a6f6..d48f25cd37 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -43,6 +43,7 @@ pub(crate) fn new_active_exec_command( command: Vec, parsed: Vec, source: ExecCommandSource, + host_id: Option, interaction_input: Option, animations_enabled: bool, ) -> ExecCell { @@ -53,6 +54,7 @@ pub(crate) fn new_active_exec_command( parsed, output: None, source, + host_id, start_time: Some(Instant::now()), duration: None, interaction_input, @@ -61,6 +63,13 @@ pub(crate) fn new_active_exec_command( ) } +fn remote_host_title_suffix(host_id: Option<&str>) -> String { + host_id + .filter(|host_id| !host_id.trim().is_empty()) + .map(|host_id| format!(" on {host_id}")) + .unwrap_or_default() +} + fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String { let command_display = if let Some((_, script)) = extract_bash_command(command) { script.to_string() @@ -261,6 +270,8 @@ impl ExecCell { fn exploring_display_lines(&self, width: u16) -> Vec> { let mut out: Vec> = Vec::new(); + let host_suffix = + remote_host_title_suffix(self.calls.first().and_then(|call| call.host_id.as_deref())); out.push(Line::from(vec![ if self.is_active() { spinner(self.active_start_time(), self.animations_enabled()) @@ -269,9 +280,9 @@ impl ExecCell { }, " ".into(), if self.is_active() { - "Exploring".bold() + format!("Exploring{host_suffix}").bold() } else { - "Explored".bold() + format!("Explored{host_suffix}").bold() }, ])); @@ -375,13 +386,16 @@ impl ExecCell { }; let is_interaction = call.is_unified_exec_interaction(); let title = if is_interaction { - "" + String::new() } else if self.is_active() { - "Running" + format!( + "Running{}", + remote_host_title_suffix(call.host_id.as_deref()) + ) } else if call.is_user_shell_command() { - "You ran" + "You ran".to_string() } else { - "Ran" + format!("Ran{}", remote_host_title_suffix(call.host_id.as_deref())) }; let mut header_line = if is_interaction { @@ -784,6 +798,7 @@ mod tests { parsed: Vec::new(), output: Some(output), source: ExecCommandSource::UserShell, + host_id: None, start_time: None, duration: None, interaction_input: None, @@ -933,6 +948,7 @@ mod tests { parsed: Vec::new(), output: None, source: ExecCommandSource::UserShell, + host_id: None, start_time: None, duration: None, interaction_input: None, @@ -970,6 +986,7 @@ mod tests { }], output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: None, duration: None, interaction_input: None, @@ -1011,6 +1028,7 @@ mod tests { aggregated_output: url.to_string(), }), source: ExecCommandSource::UserShell, + host_id: None, start_time: None, duration: None, interaction_input: None, @@ -1048,6 +1066,7 @@ mod tests { aggregated_output: url.to_string(), }), source: ExecCommandSource::Agent, + host_id: None, start_time: None, duration: None, interaction_input: None, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 987005db02..0a1afee060 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3959,6 +3959,7 @@ mod tests { ], output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, @@ -3986,6 +3987,7 @@ mod tests { }], output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, @@ -4005,6 +4007,7 @@ mod tests { path: "shimmer.rs".into(), }], ExecCommandSource::Agent, + /*host_id*/ None, /*interaction_input*/ None, ) .unwrap(); @@ -4020,6 +4023,7 @@ mod tests { path: "status_indicator_widget.rs".into(), }], ExecCommandSource::Agent, + /*host_id*/ None, /*interaction_input*/ None, ) .unwrap(); @@ -4055,6 +4059,7 @@ mod tests { ], output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, @@ -4079,6 +4084,7 @@ mod tests { parsed: Vec::new(), output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, @@ -4105,6 +4111,7 @@ mod tests { parsed: Vec::new(), output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, @@ -4129,6 +4136,7 @@ mod tests { parsed: Vec::new(), output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, @@ -4152,6 +4160,7 @@ mod tests { parsed: Vec::new(), output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, @@ -4176,6 +4185,7 @@ mod tests { parsed: Vec::new(), output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, @@ -4200,6 +4210,7 @@ mod tests { parsed: Vec::new(), output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, @@ -4250,6 +4261,7 @@ mod tests { parsed: Vec::new(), output: None, source: ExecCommandSource::Agent, + host_id: None, start_time: Some(Instant::now()), duration: None, interaction_input: None, diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index bca5f1f360..c39590c083 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -1012,6 +1012,7 @@ mod tests { vec!["bash".into(), "-lc".into(), "ls".into()], vec![ParsedCommand::Unknown { cmd: "ls".into() }], ExecCommandSource::Agent, + /*host_id*/ None, /*interaction_input*/ None, /*animations_enabled*/ true, );