diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0d6911d2..0dc335a2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -76,6 +76,7 @@ pub(crate) struct ChatWidget<'a> { // Track the most recently active stream kind in the current turn last_stream_kind: Option, running_commands: HashMap, + pending_exec_completions: Vec<(Vec, Vec, CommandOutput)>, task_complete_pending: bool, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, @@ -112,6 +113,10 @@ impl ChatWidget<'_> { fn mark_needs_redraw(&mut self) { self.needs_redraw = true; } + fn flush_answer_stream_with_separator(&mut self) { + let sink = AppEventHistorySink(self.app_event_tx.clone()); + let _ = self.stream.finalize(StreamKind::Answer, true, &sink); + } // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane @@ -215,6 +220,7 @@ impl ChatWidget<'_> { } fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { + self.flush_answer_stream_with_separator(); let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); } @@ -344,12 +350,11 @@ impl ChatWidget<'_> { pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { let running = self.running_commands.remove(&ev.call_id); - self.active_exec_cell = None; let (command, parsed) = match running { Some(rc) => (rc.command, rc.parsed_cmd), None => (vec![ev.call_id.clone()], Vec::new()), }; - self.add_to_history(HistoryCell::new_completed_exec_command( + self.pending_exec_completions.push(( command, parsed, CommandOutput { @@ -358,6 +363,16 @@ impl ChatWidget<'_> { stderr: ev.stderr.clone(), }, )); + + if self.running_commands.is_empty() { + self.active_exec_cell = None; + let pending = std::mem::take(&mut self.pending_exec_completions); + for (command, parsed, output) in pending { + self.add_to_history(HistoryCell::new_completed_exec_command( + command, parsed, output, + )); + } + } } pub(crate) fn handle_patch_apply_end_now( @@ -372,6 +387,7 @@ impl ChatWidget<'_> { } pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { + self.flush_answer_stream_with_separator(); // Log a background summary immediately so the history is chronological. let cmdline = strip_bash_lc_and_escape(&ev.command); let text = format!( @@ -398,6 +414,7 @@ impl ChatWidget<'_> { id: String, ev: ApplyPatchApprovalRequestEvent, ) { + self.flush_answer_stream_with_separator(); self.add_to_history(HistoryCell::new_patch_event( PatchEventType::ApprovalRequest, ev.changes.clone(), @@ -423,16 +440,29 @@ impl ChatWidget<'_> { parsed_cmd: ev.parsed_cmd.clone(), }, ); - self.active_exec_cell = Some(HistoryCell::new_active_exec_command( - ev.command, - ev.parsed_cmd, - )); + // Accumulate parsed commands into a single active Exec cell so they stack + match self.active_exec_cell.as_mut() { + Some(HistoryCell::Exec(exec)) => { + exec.parsed.extend(ev.parsed_cmd); + } + _ => { + self.active_exec_cell = Some(HistoryCell::new_active_exec_command( + ev.command, + ev.parsed_cmd, + )); + } + } + + // Request a redraw so the working header and command list are visible immediately. + self.mark_needs_redraw(); } pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { + self.flush_answer_stream_with_separator(); self.add_to_history(HistoryCell::new_active_mcp_tool_call(ev.invocation)); } pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { + self.flush_answer_stream_with_separator(); self.add_to_history(HistoryCell::new_completed_mcp_tool_call( 80, ev.invocation, @@ -494,6 +524,7 @@ impl ChatWidget<'_> { stream: StreamController::new(config), last_stream_kind: None, running_commands: HashMap::new(), + pending_exec_completions: Vec::new(), task_complete_pending: false, interrupts: InterruptManager::new(), needs_redraw: false, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 73497620..adde76e8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -15,6 +15,8 @@ use codex_core::protocol::AgentReasoningDeltaEvent; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::FileChange; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; @@ -134,6 +136,7 @@ fn make_chatwidget_manual() -> ( stream: StreamController::new(cfg), last_stream_kind: None, running_commands: HashMap::new(), + pending_exec_completions: Vec::new(), task_complete_pending: false, interrupts: InterruptManager::new(), needs_redraw: false, @@ -188,6 +191,90 @@ fn open_fixture(name: &str) -> std::fs::File { File::open(name).expect("open fixture file") } +#[test] +fn exec_history_cell_shows_working_then_completed() { + let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + + // Begin command + chat.handle_codex_event(Event { + id: "call-1".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-1".into(), + command: vec!["bash".into(), "-lc".into(), "echo done".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd: vec![codex_core::parse_command::ParsedCommand::Unknown { + cmd: vec!["echo".into(), "done".into()], + }], + }), + }); + + // End command successfully + chat.handle_codex_event(Event { + id: "call-1".into(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-1".into(), + stdout: "done".into(), + stderr: String::new(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + }), + }); + + let cells = drain_insert_history(&rx); + assert_eq!( + cells.len(), + 1, + "expected only the completed exec cell to be inserted into history" + ); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("Completed"), + "expected completed exec cell to show Completed header: {blob:?}" + ); +} + +#[test] +fn exec_history_cell_shows_working_then_failed() { + let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + + // Begin command + chat.handle_codex_event(Event { + id: "call-2".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-2".into(), + command: vec!["bash".into(), "-lc".into(), "false".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd: vec![codex_core::parse_command::ParsedCommand::Unknown { + cmd: vec!["false".into()], + }], + }), + }); + + // End command with failure + chat.handle_codex_event(Event { + id: "call-2".into(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-2".into(), + stdout: String::new(), + stderr: "error".into(), + exit_code: 2, + duration: std::time::Duration::from_millis(7), + }), + }); + + let cells = drain_insert_history(&rx); + assert_eq!( + cells.len(), + 1, + "expected only the completed exec cell to be inserted into history" + ); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("Failed (exit 2)"), + "expected completed exec cell to show Failed header with exit code: {blob:?}" + ); +} + #[tokio::test(flavor = "current_thread")] async fn binary_size_transcript_matches_ideal_fixture() { let (mut chat, rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8b26f845..ffbc96c1 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -342,7 +342,11 @@ impl HistoryCell { parsed_commands: &[ParsedCommand], output: Option<&CommandOutput>, ) -> Vec> { - let mut lines: Vec = vec![Line::from("⚙︎ Working")]; + let mut lines: Vec = vec![match output { + None => Line::from("⚙︎ Working".magenta().bold()), + Some(o) if o.exit_code == 0 => Line::from("✓ Completed".green().bold()), + Some(o) => Line::from(format!("✗ Failed (exit {})", o.exit_code).red().bold()), + }]; for (i, parsed) in parsed_commands.iter().enumerate() { let text = match parsed {