mirror of
https://github.com/openai/codex.git
synced 2026-09-15 12:08:01 +00:00
chore[multi-agent]: debug UI
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<dyn HistoryCell>),
|
||||
|
||||
/// Insert lines into the inline viewport only (do not persist to transcript).
|
||||
InsertEphemeralHistoryLines(Vec<Line<'static>>),
|
||||
|
||||
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),
|
||||
|
||||
|
||||
@@ -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<Box<dyn HistoryCell>>,
|
||||
stream_controller: Option<StreamController>,
|
||||
running_commands: HashMap<String, RunningCommand>,
|
||||
suppressed_exec_calls: HashSet<String>,
|
||||
last_unified_wait: Option<UnifiedExecWaitState>,
|
||||
task_complete_pending: bool,
|
||||
unified_exec_sessions: Vec<UnifiedExecSessionSummary>,
|
||||
interrupts: InterruptManager,
|
||||
reasoning_buffer: String,
|
||||
full_reasoning_buffer: String,
|
||||
current_status_header: String,
|
||||
retry_status_header: Option<String>,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
rate_limit_snapshot: Option<RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState,
|
||||
is_review_mode: bool,
|
||||
pre_review_token_info: Option<Option<TokenUsageInfo>>,
|
||||
needs_final_message_separator: bool,
|
||||
is_task_running: bool,
|
||||
replay_messages: Vec<ReplayMessage>,
|
||||
}
|
||||
|
||||
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<Line<'static>>,
|
||||
is_first_line: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct ChatWidget {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
@@ -354,12 +474,21 @@ pub(crate) struct ChatWidget {
|
||||
pre_review_token_info: Option<Option<TokenUsageInfo>>,
|
||||
// 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<ReplayMessage>,
|
||||
|
||||
last_rendered_width: std::cell::Cell<Option<usize>>,
|
||||
// Feedback sink for /feedback
|
||||
feedback: codex_feedback::CodexFeedback,
|
||||
// Current session rollout path (if known)
|
||||
current_rollout_path: Option<PathBuf>,
|
||||
selected_agent_id: String,
|
||||
known_agents: Vec<String>,
|
||||
known_agents_set: HashSet<String>,
|
||||
pending_agent_events: HashMap<String, Vec<EventMsg>>,
|
||||
agent_states: HashMap<String, AgentViewState>,
|
||||
approval_agent_ids: HashMap<String, String>,
|
||||
elicitation_agent_ids: HashMap<RequestId, String>,
|
||||
}
|
||||
|
||||
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<dyn HistoryCell>) {
|
||||
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::<history_cell::UserHistoryCell>()
|
||||
{
|
||||
self.replay_messages
|
||||
.push(ReplayMessage::UserPrompt(user.message.clone()));
|
||||
} else if let Some(agent) = cell
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::AgentMessageCell>()
|
||||
{
|
||||
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<SelectionItem> = 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<SelectionAction> = 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<Line<'static>> = Vec::new();
|
||||
let mut has_emitted = false;
|
||||
for replay in &self.replay_messages {
|
||||
let (cell, is_stream_continuation): (Box<dyn HistoryCell>, 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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -243,6 +243,10 @@ impl AgentMessageCell {
|
||||
is_first_line,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replay_snapshot(&self) -> (Vec<Line<'static>>, 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<String>) -> 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
|
||||
|
||||
Reference in New Issue
Block a user