From ee1f92012cd97a831224db64a598c9afe7213b94 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 23 Dec 2025 10:08:46 +0100 Subject: [PATCH 1/4] chore[multi-agent]: debug UI --- codex-rs/tui/src/app.rs | 19 + codex-rs/tui/src/app_event.rs | 16 + codex-rs/tui/src/chatwidget.rs | 503 +++++++++++++++++++++++++-- codex-rs/tui/src/chatwidget/tests.rs | 109 ++++++ codex-rs/tui/src/history_cell.rs | 9 + 5 files changed, 632 insertions(+), 24 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ec0da52473a..193252c0105 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -681,6 +681,19 @@ impl App { } } } + AppEvent::InsertEphemeralHistoryLines(lines) => { + if lines.is_empty() { + return Ok(true); + } + // Ephemeral lines are appended to the inline viewport only and do not + // update the persisted transcript_cells. + self.has_emitted_history_lines = true; + if self.overlay.is_some() { + self.deferred_history_lines.extend(lines); + } else { + tui.insert_history_lines(lines); + } + } AppEvent::StartCommitAnimation => { if self .commit_anim_running @@ -724,6 +737,12 @@ impl App { return Ok(false); } AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), + AppEvent::SwitchAgent { agent_id } => { + self.chat_widget.switch_to_agent(agent_id); + } + AppEvent::CreateAgentFromComposer { agent_id } => { + self.chat_widget.create_agent_from_composer(agent_id); + } AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane self.chat_widget.on_diff_complete(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0be556de8bd..1661f6693e9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -6,6 +6,7 @@ use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; use codex_protocol::openai_models::ModelPreset; +use ratatui::text::Line; use crate::bottom_pane::ApprovalRequest; use crate::history_cell::HistoryCell; @@ -54,6 +55,9 @@ pub(crate) enum AppEvent { InsertHistoryCell(Box), + /// Insert lines into the inline viewport only (do not persist to transcript). + InsertEphemeralHistoryLines(Vec>), + StartCommitAnimation, StopCommitAnimation, CommitTick, @@ -156,6 +160,18 @@ pub(crate) enum AppEvent { /// Re-open the approval presets popup. OpenApprovalsPopup, + /// Switch the active agent view. + SwitchAgent { + agent_id: String, + }, + + /// Create (if needed) a new agent and submit the current composer draft to it. + /// + /// Triggered by Ctrl+N in the chat view. + CreateAgentFromComposer { + agent_id: String, + }, + /// Forwarded conversation history snapshot from the current conversation. ConversationHistory(ConversationPathResponseEvent), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a5deb6a793b..6c8c0300578 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -144,6 +144,7 @@ use codex_file_search::FileMatch; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::UpdatePlanArgs; +use mcp_types::RequestId; use strum::IntoEnumIterator; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; @@ -155,6 +156,116 @@ struct RunningCommand { source: ExecCommandSource, } +struct AgentViewState { + active_cell: Option>, + stream_controller: Option, + running_commands: HashMap, + suppressed_exec_calls: HashSet, + last_unified_wait: Option, + task_complete_pending: bool, + unified_exec_sessions: Vec, + interrupts: InterruptManager, + reasoning_buffer: String, + full_reasoning_buffer: String, + current_status_header: String, + retry_status_header: Option, + token_info: Option, + rate_limit_snapshot: Option, + plan_type: Option, + rate_limit_warnings: RateLimitWarningState, + rate_limit_switch_prompt: RateLimitSwitchPromptState, + is_review_mode: bool, + pre_review_token_info: Option>, + needs_final_message_separator: bool, + is_task_running: bool, + replay_messages: Vec, +} + +impl AgentViewState { + fn empty() -> Self { + Self { + active_cell: None, + stream_controller: None, + running_commands: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + task_complete_pending: false, + unified_exec_sessions: Vec::new(), + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, + token_info: None, + rate_limit_snapshot: None, + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + is_task_running: false, + replay_messages: Vec::new(), + } + } + + fn capture_from(widget: &mut ChatWidget) -> Self { + Self { + active_cell: widget.active_cell.take(), + stream_controller: widget.stream_controller.take(), + running_commands: std::mem::take(&mut widget.running_commands), + suppressed_exec_calls: std::mem::take(&mut widget.suppressed_exec_calls), + last_unified_wait: widget.last_unified_wait.take(), + task_complete_pending: widget.task_complete_pending, + unified_exec_sessions: std::mem::take(&mut widget.unified_exec_sessions), + interrupts: std::mem::take(&mut widget.interrupts), + reasoning_buffer: std::mem::take(&mut widget.reasoning_buffer), + full_reasoning_buffer: std::mem::take(&mut widget.full_reasoning_buffer), + current_status_header: std::mem::take(&mut widget.current_status_header), + retry_status_header: widget.retry_status_header.take(), + token_info: widget.token_info.take(), + rate_limit_snapshot: widget.rate_limit_snapshot.take(), + plan_type: widget.plan_type.take(), + rate_limit_warnings: std::mem::take(&mut widget.rate_limit_warnings), + rate_limit_switch_prompt: std::mem::take(&mut widget.rate_limit_switch_prompt), + is_review_mode: widget.is_review_mode, + pre_review_token_info: widget.pre_review_token_info.take(), + needs_final_message_separator: widget.needs_final_message_separator, + is_task_running: widget.bottom_pane.is_task_running(), + replay_messages: std::mem::take(&mut widget.replay_messages), + } + } + + fn restore_into(self, widget: &mut ChatWidget) { + widget.active_cell = self.active_cell; + widget.stream_controller = self.stream_controller; + widget.running_commands = self.running_commands; + widget.suppressed_exec_calls = self.suppressed_exec_calls; + widget.last_unified_wait = self.last_unified_wait; + widget.task_complete_pending = self.task_complete_pending; + widget.unified_exec_sessions = self.unified_exec_sessions; + widget.interrupts = self.interrupts; + widget.reasoning_buffer = self.reasoning_buffer; + widget.full_reasoning_buffer = self.full_reasoning_buffer; + widget.current_status_header = self.current_status_header; + widget.retry_status_header = self.retry_status_header; + widget.rate_limit_snapshot = self.rate_limit_snapshot; + widget.plan_type = self.plan_type; + widget.rate_limit_warnings = self.rate_limit_warnings; + widget.rate_limit_switch_prompt = self.rate_limit_switch_prompt; + widget.is_review_mode = self.is_review_mode; + widget.pre_review_token_info = self.pre_review_token_info; + widget.needs_final_message_separator = self.needs_final_message_separator; + widget.replay_messages = self.replay_messages; + widget.bottom_pane.set_task_running(self.is_task_running); + widget + .bottom_pane + .update_status_header(widget.current_status_header.clone()); + widget.set_token_info(self.token_info); + widget.sync_unified_exec_footer(); + } +} + struct UnifiedExecSessionSummary { key: String, command_display: String, @@ -302,6 +413,15 @@ enum RateLimitSwitchPromptState { Shown, } +#[derive(Clone, Debug)] +enum ReplayMessage { + UserPrompt(String), + AgentChunk { + lines: Vec>, + is_first_line: bool, + }, +} + pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, @@ -354,12 +474,21 @@ pub(crate) struct ChatWidget { pre_review_token_info: Option>, // Whether to add a final message separator after the last message needs_final_message_separator: bool, + // Cached user/agent messages for ephemeral replay when switching agent views. + replay_messages: Vec, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback feedback: codex_feedback::CodexFeedback, // Current session rollout path (if known) current_rollout_path: Option, + selected_agent_id: String, + known_agents: Vec, + known_agents_set: HashSet, + pending_agent_events: HashMap>, + agent_states: HashMap, + approval_agent_ids: HashMap, + elicitation_agent_ids: HashMap, } struct UserMessage { @@ -1374,6 +1503,7 @@ impl ChatWidget { let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); + let default_agent_id = DEFAULT_AGENT_ID.as_str().to_string(); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1424,9 +1554,17 @@ impl ChatWidget { is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + replay_messages: Vec::new(), last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, + selected_agent_id: default_agent_id.clone(), + known_agents: vec![default_agent_id.clone()], + known_agents_set: HashSet::from([default_agent_id]), + pending_agent_events: HashMap::new(), + agent_states: HashMap::new(), + approval_agent_ids: HashMap::new(), + elicitation_agent_ids: HashMap::new(), }; widget.prefetch_rate_limits(); @@ -1460,6 +1598,7 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let default_agent_id = DEFAULT_AGENT_ID.as_str().to_string(); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1510,9 +1649,17 @@ impl ChatWidget { is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + replay_messages: Vec::new(), last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, + selected_agent_id: default_agent_id.clone(), + known_agents: vec![default_agent_id.clone()], + known_agents_set: HashSet::from([default_agent_id]), + pending_agent_events: HashMap::new(), + agent_states: HashMap::new(), + approval_agent_ids: HashMap::new(), + elicitation_agent_ids: HashMap::new(), }; widget.prefetch_rate_limits(); @@ -1531,6 +1678,29 @@ impl ChatWidget { self.on_ctrl_c(); return; } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'a') => { + self.open_agents_popup(); + return; + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) + && c.eq_ignore_ascii_case(&'n') + && !self.bottom_pane.composer_is_empty() => + { + // Avoid clobbering the composer's Ctrl+N history navigation when the + // composer is empty. When there's a draft, Ctrl+N is "new agent". + self.open_new_agent_prompt(); + return; + } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -1788,6 +1958,19 @@ impl ChatWidget { } } + fn insert_history_cell_without_flushing(&mut self, cell: Box) { + if !cell.display_lines(u16::MAX).is_empty() { + self.needs_final_message_separator = true; + } + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + } + + fn insert_agent_view_header(&mut self, agent_id: &str) { + self.insert_history_cell_without_flushing(Box::new(history_cell::new_agent_view_header( + agent_id.to_string(), + ))); + } + fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { self.add_boxed_history(Box::new(cell)); } @@ -1798,6 +1981,22 @@ impl ChatWidget { self.flush_active_cell(); self.needs_final_message_separator = true; } + if let Some(user) = cell + .as_any() + .downcast_ref::() + { + self.replay_messages + .push(ReplayMessage::UserPrompt(user.message.clone())); + } else if let Some(agent) = cell + .as_any() + .downcast_ref::() + { + let (lines, is_first_line) = agent.replay_snapshot(); + self.replay_messages.push(ReplayMessage::AgentChunk { + lines, + is_first_line, + }); + } self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } @@ -1854,19 +2053,11 @@ impl ChatWidget { } } - self.codex_op_tx - .send(Op::UserInput { items }) - .unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); + self.submit_op(Op::UserInput { items }); // Persist the text to cross-session message history. if !text.is_empty() { - self.codex_op_tx - .send(Op::AddToHistory { text: text.clone() }) - .unwrap_or_else(|e| { - tracing::error!("failed to send AddHistory op: {e}"); - }); + self.submit_op(Op::AddToHistory { text: text.clone() }); } // Only show the text portion in conversation history. @@ -1893,22 +2084,29 @@ impl ChatWidget { pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg, agent_id } = event; - if !self.should_handle_agent_event(agent_id.as_deref(), &msg) { + if self.should_handle_event_globally(&msg) { + self.dispatch_event_msg(Some(id), msg, false); return; } - self.dispatch_event_msg(Some(id), msg, false); - } - fn should_handle_agent_event(&self, agent_id: Option<&str>, msg: &EventMsg) -> bool { - let agent_id = agent_id.unwrap_or(&DEFAULT_AGENT_ID); - if agent_id == *DEFAULT_AGENT_ID { - return true; + let resolved_agent_id = agent_id.unwrap_or_else(|| DEFAULT_AGENT_ID.as_str().to_string()); + self.register_agent(&resolved_agent_id); + + if self.is_approval_event(&msg) { + self.track_approval_agent(&msg, &id, &resolved_agent_id); + self.dispatch_event_msg(Some(id), msg, false); + return; } - matches!( - msg, - EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) - ) + if resolved_agent_id == self.selected_agent_id { + self.dispatch_event_msg(Some(id), msg, false); + return; + } + + self.pending_agent_events + .entry(resolved_agent_id) + .or_default() + .push(msg); } /// Dispatch a protocol `EventMsg` to the appropriate handler. @@ -2097,6 +2295,50 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + fn register_agent(&mut self, agent_id: &str) { + if self.known_agents_set.insert(agent_id.to_string()) { + self.known_agents.push(agent_id.to_string()); + } + } + + fn should_handle_event_globally(&self, msg: &EventMsg) -> bool { + matches!( + msg, + EventMsg::SessionConfigured(_) + | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::ListSkillsResponse(_) + | EventMsg::SkillsUpdateAvailable + | EventMsg::McpStartupUpdate(_) + | EventMsg::McpStartupComplete(_) + | EventMsg::ShutdownComplete + ) + } + + fn is_approval_event(&self, msg: &EventMsg) -> bool { + matches!( + msg, + EventMsg::ExecApprovalRequest(_) + | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::ElicitationRequest(_) + ) + } + + fn track_approval_agent(&mut self, msg: &EventMsg, id: &str, agent_id: &str) { + match msg { + EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { + if !id.is_empty() { + self.approval_agent_ids + .insert(id.to_string(), agent_id.to_string()); + } + } + EventMsg::ElicitationRequest(ElicitationRequestEvent { id: request_id, .. }) => { + self.elicitation_agent_ids + .insert(request_id.clone(), agent_id.to_string()); + } + _ => {} + } + } + fn notify(&mut self, notification: Notification) { if !notification.allowed_for(&self.config.tui_notifications) { return; @@ -2761,6 +3003,160 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + pub(crate) fn open_agents_popup(&mut self) { + let mut items: Vec = Vec::new(); + let mut initial_selected_idx = None; + for (idx, agent_id) in self.known_agents.iter().enumerate() { + let is_current = agent_id == &self.selected_agent_id; + if is_current { + initial_selected_idx = Some(idx); + } + let agent_id_for_action = agent_id.clone(); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::SwitchAgent { + agent_id: agent_id_for_action.clone(), + }); + })]; + items.push(SelectionItem { + name: agent_id.clone(), + description: Some("View messages and send input to this agent.".to_string()), + is_current, + is_default: agent_id == DEFAULT_AGENT_ID.as_str(), + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Agents".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + header: Box::new(()), + ..Default::default() + }); + self.request_redraw(); + } + + pub(crate) fn open_new_agent_prompt(&mut self) { + let app_event_tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "New agent".to_string(), + "Enter a new agent id, then press Enter to send your current draft.".to_string(), + Some("Ctrl+N: create a new agent from the current composer draft".to_string()), + Box::new(move |agent_id| { + app_event_tx.send(AppEvent::CreateAgentFromComposer { agent_id }); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + + pub(crate) fn create_agent_from_composer(&mut self, agent_id: String) { + let agent_id = agent_id.trim().to_string(); + if agent_id.is_empty() { + self.add_to_history(history_cell::new_info_event( + "Agent id cannot be empty.".to_string(), + None, + )); + return; + } + + self.switch_to_agent(agent_id); + + // Reuse the composer's normal submission pipeline (trim/expansion/etc.) + // by simulating an Enter press. + match self + .bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + { + InputResult::Submitted(text) => { + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::None => {} + } + } + + pub(crate) fn switch_to_agent(&mut self, agent_id: String) { + if agent_id == self.selected_agent_id { + return; + } + self.register_agent(&agent_id); + let previous_agent = self.selected_agent_id.clone(); + let previous_state = AgentViewState::capture_from(self); + self.agent_states.insert(previous_agent, previous_state); + self.bottom_pane.set_task_running(false); + self.selected_agent_id = agent_id.clone(); + let next_state = self + .agent_states + .remove(&agent_id) + .unwrap_or_else(AgentViewState::empty); + next_state.restore_into(self); + self.insert_agent_view_header(&agent_id); + self.replay_user_and_agent_messages(); + if let Some(pending) = self.pending_agent_events.remove(&agent_id) { + for msg in pending { + self.dispatch_event_msg(None, msg, false); + } + } + self.request_redraw(); + } + + fn replay_user_and_agent_messages(&mut self) { + if self.replay_messages.is_empty() { + return; + } + let width = self + .last_rendered_width + .get() + .and_then(|w| u16::try_from(w).ok()) + .unwrap_or(u16::MAX); + let mut out: Vec> = Vec::new(); + let mut has_emitted = false; + for replay in &self.replay_messages { + let (cell, is_stream_continuation): (Box, bool) = match replay { + ReplayMessage::UserPrompt(message) => ( + Box::new(history_cell::new_user_prompt(message.clone())), + false, + ), + ReplayMessage::AgentChunk { + lines, + is_first_line, + } => ( + Box::new(history_cell::AgentMessageCell::new( + lines.clone(), + *is_first_line, + )), + !*is_first_line, + ), + }; + let mut display = cell.display_lines(width); + if display.is_empty() { + continue; + } + if !is_stream_continuation { + if has_emitted { + out.push(Line::from("")); + } else { + has_emitted = true; + } + } + out.append(&mut display); + } + if !out.is_empty() { + self.app_event_tx + .send(AppEvent::InsertEphemeralHistoryLines(out)); + } + } + fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, @@ -3216,10 +3612,69 @@ impl ChatWidget { self.bottom_pane.clear_esc_backtrack_hint(); } /// Forward an `Op` directly to codex. - pub(crate) fn submit_op(&self, op: Op) { + fn wrap_for_agent(&self, agent_id: String, op: Op) -> Op { + if agent_id == DEFAULT_AGENT_ID.as_str() { + op + } else { + Op::ForAgent { + agent_id, + op: Box::new(op), + } + } + } + + fn route_op_for_agent(&mut self, op: Op) -> Op { + match op { + Op::ForAgent { agent_id, op } => { + self.register_agent(&agent_id); + Op::ForAgent { agent_id, op } + } + Op::ExecApproval { id, decision } => { + let agent_id = self + .approval_agent_ids + .remove(&id) + .unwrap_or_else(|| self.selected_agent_id.clone()); + self.register_agent(&agent_id); + self.wrap_for_agent(agent_id, Op::ExecApproval { id, decision }) + } + Op::PatchApproval { id, decision } => { + let agent_id = self + .approval_agent_ids + .remove(&id) + .unwrap_or_else(|| self.selected_agent_id.clone()); + self.register_agent(&agent_id); + self.wrap_for_agent(agent_id, Op::PatchApproval { id, decision }) + } + Op::ResolveElicitation { + server_name, + request_id, + decision, + } => { + let agent_id = self + .elicitation_agent_ids + .remove(&request_id) + .unwrap_or_else(|| self.selected_agent_id.clone()); + self.register_agent(&agent_id); + let op = Op::ResolveElicitation { + server_name, + request_id, + decision, + }; + self.wrap_for_agent(agent_id, op) + } + op => { + let agent_id = self.selected_agent_id.clone(); + self.register_agent(&agent_id); + self.wrap_for_agent(agent_id, op) + } + } + } + + pub(crate) fn submit_op(&mut self, op: Op) { // Record outbound operation for session replay fidelity. - crate::session_log::log_outbound_op(&op); - if let Err(e) = self.codex_op_tx.send(op) { + let routed = self.route_op_for_agent(op); + crate::session_log::log_outbound_op(&routed); + if let Err(e) = self.codex_op_tx.send(routed) { tracing::error!("failed to submit op: {e}"); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a2b853ce641..46bbb2f4411 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -377,6 +377,7 @@ fn make_chatwidget_manual( skills: None, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + let default_agent_id = DEFAULT_AGENT_ID.as_str().to_string(); let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -415,9 +416,17 @@ fn make_chatwidget_manual( is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + replay_messages: Vec::new(), last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, + selected_agent_id: default_agent_id.clone(), + known_agents: vec![default_agent_id.clone()], + known_agents_set: HashSet::from([default_agent_id]), + pending_agent_events: HashMap::new(), + agent_states: HashMap::new(), + approval_agent_ids: HashMap::new(), + elicitation_agent_ids: HashMap::new(), }; (widget, rx, op_rx) } @@ -3303,6 +3312,106 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { assert!(first_idx < second_idx, "messages out of order: {combined}"); } +#[test] +fn non_selected_agent_events_buffer_until_switch() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "ev1".into(), + agent_id: Some("worker_1".into()), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "hello from worker".into(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!(cells.is_empty(), "expected no history while not selected"); + + chat.switch_to_agent("worker_1".to_string()); + + let cells = drain_insert_history(&mut rx); + let combined: String = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect(); + assert!( + combined.contains("Viewing agent worker_1"), + "missing view header: {combined}" + ); + assert!( + combined.contains("hello from worker"), + "missing buffered message: {combined}" + ); +} + +#[test] +fn approval_ops_route_to_requesting_agent() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + let ev = ExecApprovalRequestEvent { + call_id: "call-1".into(), + turn_id: "turn-1".into(), + command: vec!["echo".into(), "ok".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: None, + proposed_execpolicy_amendment: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-1".into(), + agent_id: Some("worker_2".into()), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + chat.submit_op(Op::ExecApproval { + id: "sub-1".into(), + decision: codex_core::protocol::ReviewDecision::Approved, + }); + + let routed = op_rx.try_recv().expect("expected routed op"); + match routed { + Op::ForAgent { agent_id, op } => { + assert_eq!(agent_id, "worker_2"); + assert!( + matches!(*op, Op::ExecApproval { .. }), + "expected exec approval op, got {op:?}" + ); + } + other => panic!("expected ForAgent op, got {other:?}"), + } +} + +#[test] +fn ctrl_n_create_agent_from_composer_routes_submission() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + chat.set_composer_text("hello".to_string()); + + chat.create_agent_from_composer("worker_3".to_string()); + + let first = op_rx.try_recv().expect("expected first routed op"); + match first { + Op::ForAgent { agent_id, op } => { + assert_eq!(agent_id, "worker_3"); + assert!( + matches!(*op, Op::UserInput { .. }), + "expected user input op, got {op:?}" + ); + } + other => panic!("expected ForAgent op, got {other:?}"), + } + + let second = op_rx.try_recv().expect("expected second routed op"); + match second { + Op::ForAgent { agent_id, op } => { + assert_eq!(agent_id, "worker_3"); + assert!( + matches!(*op, Op::AddToHistory { .. }), + "expected add to history op, got {op:?}" + ); + } + other => panic!("expected ForAgent op, got {other:?}"), + } +} + #[test] fn final_reasoning_then_message_without_deltas_are_rendered() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2c0a37ecea5..81f930ce1e7 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -243,6 +243,10 @@ impl AgentMessageCell { is_first_line, } } + + pub(crate) fn replay_snapshot(&self) -> (Vec>, bool) { + (self.lines.clone(), self.is_first_line) + } } impl HistoryCell for AgentMessageCell { @@ -1361,6 +1365,11 @@ pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHist PlainHistoryCell { lines } } +pub(crate) fn new_agent_view_header(agent_id: String) -> PlainHistoryCell { + let line: Line<'static> = format!("Viewing agent {agent_id}").cyan().into(); + PlainHistoryCell { lines: vec![line] } +} + pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { // Use a hair space (U+200A) to create a subtle, near-invisible separation // before the text. VS16 is intentionally omitted to keep spacing tighter From c5961db8a48d3f1f5e9a3153a363d86a94df817d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 23 Dec 2025 10:27:34 +0100 Subject: [PATCH 2/4] NIT TUI --- codex-rs/tui/src/chatwidget.rs | 10 +++++----- codex-rs/tui/src/chatwidget/tests.rs | 2 +- codex-rs/tui/src/history_cell.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6c8c0300578..28602a4f500 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3019,7 +3019,7 @@ impl ChatWidget { })]; items.push(SelectionItem { name: agent_id.clone(), - description: Some("View messages and send input to this agent.".to_string()), + description: None, is_current, is_default: agent_id == DEFAULT_AGENT_ID.as_str(), actions, @@ -3029,7 +3029,7 @@ impl ChatWidget { } self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Agents".to_string()), + title: Some("Switch agent".to_string()), footer_hint: Some(standard_popup_hint_line()), items, initial_selected_idx, @@ -3042,9 +3042,9 @@ impl ChatWidget { pub(crate) fn open_new_agent_prompt(&mut self) { let app_event_tx = self.app_event_tx.clone(); let view = CustomPromptView::new( - "New agent".to_string(), - "Enter a new agent id, then press Enter to send your current draft.".to_string(), - Some("Ctrl+N: create a new agent from the current composer draft".to_string()), + "Spawn new agent".to_string(), + "Enter a new agent id, then press Enter to send your current message.".to_string(), + Some("Create a new agent from the current message".to_string()), Box::new(move |agent_id| { app_event_tx.send(AppEvent::CreateAgentFromComposer { agent_id }); }), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 46bbb2f4411..434df890780 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3335,7 +3335,7 @@ fn non_selected_agent_events_buffer_until_switch() { .map(|lines| lines_to_single_string(lines)) .collect(); assert!( - combined.contains("Viewing agent worker_1"), + combined.contains("Viewing agent `worker_1`"), "missing view header: {combined}" ); assert!( diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 81f930ce1e7..244bb71bef2 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1366,7 +1366,7 @@ pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHist } pub(crate) fn new_agent_view_header(agent_id: String) -> PlainHistoryCell { - let line: Line<'static> = format!("Viewing agent {agent_id}").cyan().into(); + let line: Line<'static> = format!("Viewing agent `{agent_id}`").cyan().into(); PlainHistoryCell { lines: vec![line] } } From 9fead1f2903fbe9a9043a69d181d0f9583a741bd Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 23 Dec 2025 11:12:00 +0100 Subject: [PATCH 3/4] NIT --- codex-rs/tui/src/chatwidget/tests.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d70c1b7f980..5ca99b89cbf 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -911,6 +911,7 @@ fn begin_unified_exec_startup( }; chat.handle_codex_event(Event { id: call_id.to_string(), + agent_id: None, msg: EventMsg::ExecCommandBegin(event.clone()), }); event @@ -919,6 +920,7 @@ fn begin_unified_exec_startup( fn terminal_interaction(chat: &mut ChatWidget, call_id: &str, process_id: &str, stdin: &str) { chat.handle_codex_event(Event { id: call_id.to_string(), + agent_id: None, msg: EventMsg::TerminalInteraction(TerminalInteractionEvent { call_id: call_id.to_string(), process_id: process_id.to_string(), @@ -1324,6 +1326,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { chat.handle_codex_event(Event { id: "turn-wait-1".into(), + agent_id: None, msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: None, }), @@ -1373,6 +1376,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { chat.handle_codex_event(Event { id: "turn-wait-3".into(), + agent_id: None, msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: None, }), @@ -3420,9 +3424,9 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { assert!(first_idx < second_idx, "messages out of order: {combined}"); } -#[test] -fn non_selected_agent_events_buffer_until_switch() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); +#[tokio::test] +async fn non_selected_agent_events_buffer_until_switch() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "ev1".into(), @@ -3452,9 +3456,9 @@ fn non_selected_agent_events_buffer_until_switch() { ); } -#[test] -fn approval_ops_route_to_requesting_agent() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); +#[tokio::test] +async fn approval_ops_route_to_requesting_agent() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; let ev = ExecApprovalRequestEvent { call_id: "call-1".into(), turn_id: "turn-1".into(), @@ -3488,9 +3492,9 @@ fn approval_ops_route_to_requesting_agent() { } } -#[test] -fn ctrl_n_create_agent_from_composer_routes_submission() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); +#[tokio::test] +async fn ctrl_n_create_agent_from_composer_routes_submission() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; chat.set_composer_text("hello".to_string()); chat.create_agent_from_composer("worker_3".to_string()); From 328ef2585f1c4d4509b622bad49b52f0924b8363 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sat, 27 Dec 2025 18:17:48 +0100 Subject: [PATCH 4/4] switch shortcut --- codex-rs/tui/src/chatwidget.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9f0b91ad94c..3c3583436a8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1737,7 +1737,7 @@ impl ChatWidget { modifiers, kind: KeyEventKind::Press, .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'a') => { + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'o') => { self.open_agents_popup(); return; }