From 90d339cf751cde8f7b6b9d427df3908fff12e976 Mon Sep 17 00:00:00 2001 From: Friel Date: Thu, 5 Mar 2026 13:53:23 -0800 Subject: [PATCH] fix(tui): mount subagent panel independently of active cell --- codex-rs/tui/src/app.rs | 575 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 429 +++++++++++++ ..._while_placeholder_active_cell_exists.snap | 18 + codex-rs/tui/src/chatwidget/tests.rs | 62 +- codex-rs/tui/src/history_cell.rs | 39 +- 5 files changed, 1112 insertions(+), 11 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__subagent_panel_mounts_while_placeholder_active_cell_exists.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3945f32f57..482b3c770d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -693,6 +693,523 @@ impl ThreadEventChannel { } } +const SUBAGENT_PROMPT_PREVIEW_BUDGET: usize = 120; +const SUBAGENT_UPDATE_PREVIEW_BUDGET: usize = 160; +const SUBAGENT_PENDING_EVENT_CAPACITY: usize = 12; +const SUBAGENT_ANIMATION_TICK: Duration = Duration::from_millis(100); +const SUBAGENT_SHIMMER_WINDOW: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone)] +struct SubagentInfo { + ordinal: i32, + name: String, + prompt_preview: String, + spawn_mode: CollabAgentSpawnMode, + status: AgentStatus, + spawned_at: Instant, + started_at: Option, + latest_summary: String, + latest_preview: String, + latest_update_at: Instant, + inflight_message: String, + reasoning_buffer: String, + notified_terminal: bool, +} + +impl SubagentInfo { + fn new( + ordinal: i32, + name: String, + prompt_preview: String, + spawn_mode: CollabAgentSpawnMode, + ) -> Self { + let now = Instant::now(); + Self { + ordinal, + name, + prompt_preview: prompt_preview.clone(), + spawn_mode, + status: AgentStatus::PendingInit, + spawned_at: now, + started_at: None, + latest_summary: String::new(), + latest_preview: prompt_preview, + latest_update_at: now, + inflight_message: String::new(), + reasoning_buffer: String::new(), + notified_terminal: false, + } + } + + fn is_running(&self) -> bool { + matches!(self.status, AgentStatus::PendingInit | AgentStatus::Running) + } + + fn is_watchdog(&self) -> bool { + self.spawn_mode == CollabAgentSpawnMode::Watchdog + } + + fn is_visible_in_panel(&self) -> bool { + if self.is_watchdog() { + return matches!(self.status, AgentStatus::PendingInit | AgentStatus::Running); + } + self.is_running() + } + + fn is_running_for_panel(&self) -> bool { + if self.is_watchdog() { + return matches!(self.status, AgentStatus::Running); + } + self.is_running() + } + + fn running_started_at(&self) -> Instant { + self.started_at.unwrap_or(self.spawned_at) + } + + fn update_preview(&mut self, preview: String) { + self.latest_preview = preview; + self.latest_update_at = Instant::now(); + } + + fn update_reasoning_summary(&mut self, delta: &str) { + self.reasoning_buffer.push_str(delta); + if let Some(summary) = extract_first_bold(&self.reasoning_buffer) { + self.latest_summary = truncate_text(summary.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET); + self.latest_update_at = Instant::now(); + } + } + + fn clear_turn_buffers(&mut self) { + self.inflight_message.clear(); + self.reasoning_buffer.clear(); + self.latest_summary.clear(); + } + + fn should_shimmer(&self, now: Instant) -> bool { + if self.is_watchdog() && matches!(self.status, AgentStatus::PendingInit) { + return false; + } + self.is_running() + && now.saturating_duration_since(self.latest_update_at) <= SUBAGENT_SHIMMER_WINDOW + } +} + +#[derive(Debug, Default)] +struct SubagentRegistry { + root_thread_id: Option, + agents: HashMap, + order: Vec, + pending_events: HashMap>, + pending_history: Vec>, + panel_state: Option>>, + panel_cell: Option>, + animations_enabled: bool, +} + +impl SubagentRegistry { + fn new(animations_enabled: bool) -> Self { + Self { + animations_enabled, + ..Self::default() + } + } + + fn set_root_thread(&mut self, thread_id: ThreadId) { + self.root_thread_id = Some(thread_id); + } + + fn is_root_thread(&self, thread_id: ThreadId) -> bool { + self.root_thread_id == Some(thread_id) + } + + fn contains(&self, thread_id: ThreadId) -> bool { + self.agents.contains_key(&thread_id) + } + + fn on_spawn_end(&mut self, event: &CollabAgentSpawnEndEvent) -> Option> { + let new_thread_id = event.new_thread_id?; + if event.spawn_mode == CollabAgentSpawnMode::Watchdog { + self.prune_superseded_watchdogs(new_thread_id); + } + if self.contains(new_thread_id) { + return None; + } + + let ordinal = i32::try_from(self.order.len()) + .unwrap_or(i32::MAX - 1) + .saturating_add(1); + let prompt_preview = prompt_preview(&event.prompt); + let name = derive_subagent_name(&event.prompt, ordinal); + + let mut info = SubagentInfo::new(ordinal, name.clone(), prompt_preview, event.spawn_mode); + info.status = event.status.clone(); + info.latest_preview = info.prompt_preview.clone(); + info.latest_update_at = Instant::now(); + + self.order.push(new_thread_id); + self.agents.insert(new_thread_id, info); + + let early_events = self + .pending_events + .remove(&new_thread_id) + .unwrap_or_default(); + let mut follow_up = Vec::new(); + for msg in early_events { + follow_up.extend(self.on_agent_event(new_thread_id, &msg)); + } + for cell in follow_up { + self.queue_history(cell); + } + + let prompt_line = prompt_first_line(&event.prompt); + Some(Box::new(history_cell::new_subagent_spawned_cell( + &name, + &prompt_line, + ))) + } + + fn prune_superseded_watchdogs(&mut self, keep_thread_id: ThreadId) { + let superseded: HashSet = self + .agents + .iter() + .filter_map(|(thread_id, info)| { + (info.spawn_mode == CollabAgentSpawnMode::Watchdog && *thread_id != keep_thread_id) + .then_some(*thread_id) + }) + .collect(); + if superseded.is_empty() { + return; + } + + self.order + .retain(|thread_id| !superseded.contains(thread_id)); + self.agents + .retain(|thread_id, _| !superseded.contains(thread_id)); + self.pending_events + .retain(|thread_id, _| !superseded.contains(thread_id)); + } + + fn on_close_end(&mut self, event: &CollabCloseEndEvent) -> Option> { + let receiver_id = event.receiver_thread_id; + let info = self.agents.get_mut(&receiver_id)?; + info.status = event.status.clone(); + info.latest_update_at = Instant::now(); + + if is_terminal_status(&info.status) && !info.notified_terminal { + info.notified_terminal = true; + let summary = terminal_summary(&info.status); + return Some(Box::new(history_cell::new_subagent_update_cell( + &info.name, + &info.status, + summary.as_str(), + ))); + } + None + } + + fn on_wait_end(&mut self, event: &CollabWaitingEndEvent) { + for (thread_id, status) in &event.statuses { + let Some(info) = self.agents.get_mut(thread_id) else { + continue; + }; + info.status = status.clone(); + info.latest_update_at = Instant::now(); + } + } + + fn on_agent_event(&mut self, thread_id: ThreadId, msg: &EventMsg) -> Vec> { + let Some(info) = self.agents.get_mut(&thread_id) else { + self.buffer_pending_event(thread_id, msg.clone()); + return Vec::new(); + }; + + let mut history = Vec::new(); + match msg { + EventMsg::TurnStarted(TurnStartedEvent { .. }) => { + info.clear_turn_buffers(); + info.status = AgentStatus::Running; + if info.started_at.is_none() { + info.started_at = Some(Instant::now()); + } + } + EventMsg::AgentReasoningDelta(ev) => { + info.update_reasoning_summary(ev.delta.as_str()); + } + EventMsg::AgentReasoningRawContentDelta(ev) => { + info.update_reasoning_summary(ev.delta.as_str()); + } + EventMsg::AgentReasoningRawContent(ev) => { + info.update_reasoning_summary(ev.text.as_str()); + info.reasoning_buffer.clear(); + } + EventMsg::AgentReasoning(_) | EventMsg::AgentReasoningSectionBreak(_) => { + info.reasoning_buffer.clear(); + } + EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { + info.inflight_message.push_str(delta); + let preview = + truncate_text(info.inflight_message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET); + info.update_preview(preview); + } + EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { + info.inflight_message.clear(); + let preview = truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET); + info.update_preview(preview); + } + EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message, .. + }) => { + info.inflight_message.clear(); + info.status = AgentStatus::Completed(last_agent_message.clone()); + if !info.notified_terminal { + info.notified_terminal = true; + let summary = last_agent_message + .as_deref() + .map(|message| { + truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET) + }) + .unwrap_or_else(|| "completed".to_string()); + history.push(Box::new(history_cell::new_subagent_update_cell( + &info.name, + &info.status, + summary.as_str(), + )) as Box); + } + } + EventMsg::TurnAborted(TurnAbortedEvent { reason, .. }) => { + info.inflight_message.clear(); + let reason_text = format!("{reason:?}").to_lowercase(); + info.status = AgentStatus::Errored(reason_text.clone()); + if !info.notified_terminal { + info.notified_terminal = true; + history.push(Box::new(history_cell::new_subagent_update_cell( + &info.name, + &info.status, + reason_text.as_str(), + )) as Box); + } + } + EventMsg::Error(ErrorEvent { message, .. }) => { + info.inflight_message.clear(); + let summary = truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET); + info.status = AgentStatus::Errored(summary.clone()); + if !info.notified_terminal { + info.notified_terminal = true; + history.push(Box::new(history_cell::new_subagent_update_cell( + &info.name, + &info.status, + summary.as_str(), + )) as Box); + } + } + EventMsg::ShutdownComplete => { + info.inflight_message.clear(); + info.status = AgentStatus::Shutdown; + if !info.notified_terminal { + info.notified_terminal = true; + history.push(Box::new(history_cell::new_subagent_update_cell( + &info.name, + &info.status, + "shutdown", + )) as Box); + } + } + _ => {} + } + + if history.is_empty() && matches!(msg, EventMsg::TurnStarted(_)) { + info.latest_update_at = Instant::now(); + } + + history + } + + fn buffer_pending_event(&mut self, thread_id: ThreadId, msg: EventMsg) { + if self.is_root_thread(thread_id) { + return; + } + let entry = self.pending_events.entry(thread_id).or_default(); + entry.push(msg); + if entry.len() > SUBAGENT_PENDING_EVENT_CAPACITY { + let excess = entry.len() - SUBAGENT_PENDING_EVENT_CAPACITY; + entry.drain(0..excess); + } + } + + fn queue_history(&mut self, cell: Box) { + self.pending_history.push(cell); + } + + fn take_pending_history(&mut self) -> Vec> { + std::mem::take(&mut self.pending_history) + } + + fn has_animating_agents(&self) -> bool { + let now = Instant::now(); + self.agents.values().any(|info| info.should_shimmer(now)) + } + + fn rebuild_panel_state(&mut self) { + let mut running_infos: Vec<&SubagentInfo> = self + .agents + .values() + .filter(|info| info.is_visible_in_panel()) + .collect(); + running_infos.sort_by_key(|info| info.ordinal); + + if running_infos.is_empty() { + self.panel_state = None; + self.panel_cell = None; + return; + } + + let started_at = running_infos + .iter() + .map(|info| info.running_started_at()) + .min() + .unwrap_or_else(Instant::now); + let running_count = i32::try_from( + running_infos + .iter() + .filter(|info| info.is_running_for_panel()) + .count(), + ) + .unwrap_or(i32::MAX); + let total_agents = i32::try_from(running_infos.len()).unwrap_or(i32::MAX); + let running_agents = running_infos + .into_iter() + .map(|info| SubagentPanelAgent { + ordinal: info.ordinal, + name: info.name.clone(), + status: info.status.clone(), + is_watchdog: info.is_watchdog(), + preview: running_preview(info), + latest_update_at: info.latest_update_at, + }) + .collect(); + + let state = SubagentPanelState { + started_at, + total_agents, + running_count, + running_agents, + }; + + match &self.panel_state { + Some(existing) => { + let mut guard = existing + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *guard = state; + } + None => { + self.panel_state = Some(Arc::new(StdMutex::new(state))); + } + } + + if let Some(panel_state) = &self.panel_state { + self.panel_cell = Some(Arc::new(SubagentStatusCell::new( + Arc::clone(panel_state), + self.animations_enabled, + ))); + } + } + + fn panel_cell(&self) -> Option> { + self.panel_cell.clone() + } +} + +fn is_terminal_status(status: &AgentStatus) -> bool { + matches!( + status, + AgentStatus::Completed(_) + | AgentStatus::Errored(_) + | AgentStatus::Shutdown + | AgentStatus::NotFound + ) +} + +fn terminal_summary(status: &AgentStatus) -> String { + match status { + AgentStatus::Completed(Some(message)) => { + truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET) + } + AgentStatus::Completed(None) => "completed".to_string(), + AgentStatus::Errored(message) => { + truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET) + } + AgentStatus::Shutdown => "shutdown".to_string(), + AgentStatus::NotFound => "not found".to_string(), + AgentStatus::PendingInit | AgentStatus::Running => "running".to_string(), + } +} + +fn prompt_first_line(prompt: &str) -> String { + prompt + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or_default() + .to_string() +} + +fn prompt_preview(prompt: &str) -> String { + let first_line = prompt_first_line(prompt); + truncate_text(first_line.trim(), SUBAGENT_PROMPT_PREVIEW_BUDGET) +} + +fn running_preview(info: &SubagentInfo) -> String { + if !info.latest_summary.trim().is_empty() { + return truncate_text(info.latest_summary.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET); + } + if !info.inflight_message.trim().is_empty() { + return truncate_text(info.inflight_message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET); + } + if !info.latest_preview.trim().is_empty() { + return truncate_text(info.latest_preview.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET); + } + truncate_text(info.prompt_preview.trim(), SUBAGENT_PROMPT_PREVIEW_BUDGET) +} + +fn derive_subagent_name(prompt: &str, ordinal: i32) -> String { + let first_line = prompt_first_line(prompt); + let stripped = first_line + .strip_prefix("Task:") + .or_else(|| first_line.strip_prefix("task:")) + .unwrap_or(&first_line) + .trim(); + + let stopwords = [ + "the", "a", "an", "to", "and", "or", "of", "for", "from", "in", "on", "with", "read", + "file", "task", + ]; + + let tokens: Vec = stripped + .split_whitespace() + .map(clean_token) + .filter(|token| !token.is_empty()) + .filter(|token| !stopwords.contains(&token.as_str())) + .take(4) + .collect(); + + if tokens.is_empty() { + return format!("agent-{ordinal}"); + } + + let joined = tokens.join("-"); + truncate_text(&joined, 40) +} + +fn clean_token(token: &str) -> String { + token + .chars() + .map(|ch| ch.to_ascii_lowercase()) + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-') + .collect() +} + fn should_show_model_migration_prompt( current_model: &str, target_model: &str, @@ -6701,6 +7218,64 @@ mod tests { } } + #[tokio::test] + async fn queued_subagent_panel_update_mounts_on_fresh_chat_widget_after_thread_switch() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let root_thread_id = ThreadId::new(); + let subagent_thread_id = ThreadId::new(); + + app.primary_thread_id = Some(root_thread_id); + app.active_thread_id = Some(root_thread_id); + app.subagents.set_root_thread(root_thread_id); + + let mut info = SubagentInfo::new( + 1, + "watchdog-agent".to_string(), + "watchdog idle".to_string(), + CollabAgentSpawnMode::Watchdog, + ); + info.status = AgentStatus::PendingInit; + info.latest_preview = "watchdog idle".to_string(); + info.latest_update_at = Instant::now(); + app.subagents.order.push(subagent_thread_id); + app.subagents.agents.insert(subagent_thread_id, info); + + app.sync_subagent_panel_state(); + + let queued_panel = match app_event_rx.try_recv() { + Ok(AppEvent::UpdateSubagentPanel(panel)) => panel, + other => panic!("expected queued subagent panel update, got {other:?}"), + }; + + let (fresh_chat_widget, _fresh_app_event_tx, _fresh_rx, _fresh_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = fresh_chat_widget; + app.chat_widget.set_composer_text( + "back on the root thread".to_string(), + Vec::new(), + Vec::new(), + ); + + app.chat_widget.on_subagent_panel_updated(queued_panel); + + let width = 80; + let height = app.chat_widget.desired_height(width); + let mut terminal = + ratatui::Terminal::new(crate::test_backend::VT100Backend::new(width, height)) + .expect("create terminal"); + terminal.set_viewport_area(ratatui::prelude::Rect::new(0, 0, width, height)); + terminal + .draw(|f| app.chat_widget.render(f.area(), f.buffer_mut())) + .expect("render fresh widget with queued subagent panel"); + let screen = terminal.backend().vt100().screen().contents(); + + assert!( + screen.contains("Subagents"), + "queued subagent panel update should mount on the fresh widget" + ); + assert!(screen.contains("watchdog-agent")); + } + #[tokio::test] async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 581990073e..346f3250c2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -780,6 +780,7 @@ pub(crate) struct ChatWidget { codex_op_target: CodexOpTarget, bottom_pane: BottomPane, active_cell: Option>, + subagent_panel: Option, /// Monotonic-ish counter used to invalidate transcript overlay caching. /// /// The transcript overlay appends a cached "live tail" for the current active cell. Most @@ -4038,6 +4039,35 @@ impl ChatWidget { self.run_commit_tick(); } + pub(crate) fn on_subagent_panel_updated(&mut self, panel: Arc) { + let state_handle = panel.state_handle(); + + if let Some(existing) = self.subagent_panel.as_mut() { + if existing.matches_state(&state_handle) { + self.request_redraw(); + return; + } + *existing = panel.as_ref().clone(); + self.request_redraw(); + return; + } + + self.subagent_panel = Some(panel.as_ref().clone()); + self.request_redraw(); + } + + pub(crate) fn clear_subagent_panel(&mut self) { + if self.subagent_panel.take().is_some() { + self.request_redraw(); + } + } + + pub(crate) fn on_subagent_tick(&mut self) { + if self.subagent_panel.is_some() { + self.request_redraw(); + } + } + /// Runs a regular periodic commit tick. fn run_commit_tick(&mut self) { self.run_commit_tick_with_scope(CommitTickScope::AnyMode); @@ -4514,8 +4544,209 @@ impl ChatWidget { self.had_work_activity = true; } +<<<<<<< HEAD pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self { Self::new_with_op_target(common, CodexOpTarget::AppEvent) +======= + pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_user_message, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + is_first_run, + feedback_audience, + model, + startup_tooltip_override, + status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + session_telemetry, + } = common; + let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; + config.model = model.clone(); + let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); + let mut rng = rand::rng(); + let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); + let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); + + let model_override = model.as_deref(); + let model_for_header = model + .clone() + .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let active_collaboration_mask = + Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); + let header_model = active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.clone()) + .unwrap_or_else(|| model_for_header.clone()); + let fallback_default = Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }; + // Collaboration modes start in Default mode. + let current_collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: fallback_default, + }; + + let active_cell = Some(Self::placeholder_session_header_cell(&config)); + + let current_cwd = Some(config.cwd.clone()); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); + let mut widget = Self { + app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), + codex_op_tx, + bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + animations_enabled: config.animations, + skills: None, + }), + active_cell, + subagent_panel: None, + active_cell_revision: 0, + config, + skills_all: Vec::new(), + skills_initial_state: None, + current_collaboration_mode, + active_collaboration_mask, + auth_manager, + models_manager, + session_telemetry, + session_header: SessionHeader::new(header_model), + initial_user_message, + token_info: None, + rate_limit_snapshots_by_limit_id: BTreeMap::new(), + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + adaptive_chunking: AdaptiveChunkingPolicy::default(), + stream_controller: None, + plan_stream_controller: None, + last_copyable_output: None, + running_commands: HashMap::new(), + pending_collab_spawn_requests: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + unified_exec_wait_streak: None, + turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), + task_complete_pending: false, + unified_exec_processes: Vec::new(), + agent_turn_running: false, + mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), + connectors_partial_snapshot: None, + connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, + plugins_cache: PluginsCacheState::default(), + plugins_fetch_state: PluginListFetchState::default(), + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status: StatusIndicatorState::working(), + pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, + retry_status_header: None, + pending_status_indicator_restore: false, + suppress_queue_autosend: false, + thread_id: None, + thread_name: None, + forked_from: None, + queued_user_messages: VecDeque::new(), + pending_steers: VecDeque::new(), + submit_pending_steers_after_interrupt: false, + queued_message_edit_binding, + show_welcome_banner: is_first_run, + startup_tooltip_override, + suppress_session_configured_redraw: false, + pending_notification: None, + quit_shortcut_expires_at: None, + quit_shortcut_key: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + had_work_activity: false, + saw_plan_update_this_turn: false, + saw_plan_item_this_turn: false, + last_plan_progress: None, + plan_delta_buffer: String::new(), + plan_item_active: false, + last_separator_elapsed_secs: None, + turn_runtime_metrics: RuntimeMetricsSummary::default(), + last_rendered_width: std::cell::Cell::new(None), + feedback, + feedback_audience, + current_rollout_path: None, + current_cwd, + session_network_proxy: None, + status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, + status_line_branch: None, + status_line_branch_cwd: None, + status_line_branch_pending: false, + status_line_branch_lookup_complete: false, + external_editor_state: ExternalEditorState::Closed, + realtime_conversation: RealtimeConversationUiState::default(), + last_replayed_agent_inbox_message: None, + last_rendered_user_message_event: None, + }; + + widget.prefetch_rate_limits(); + widget.bottom_pane.set_voice_transcription_enabled( + widget.config.features.enabled(Feature::VoiceTranscription), + ); + widget + .bottom_pane + .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); + widget + .bottom_pane + .set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled()); + widget + .bottom_pane + .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); + widget + .bottom_pane + .set_collaboration_modes_enabled(/*enabled*/ true); + widget.sync_fast_command_enabled(); + widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); + #[cfg(target_os = "windows")] + widget.bottom_pane.set_windows_degraded_sandbox_active( + codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&widget.config), + WindowsSandboxLevel::RestrictedToken + ), + ); + widget.update_collaboration_mode_indicator(); + + widget + .bottom_pane + .set_connectors_enabled(widget.connectors_enabled()); + + widget.refresh_terminal_title(); + + widget } #[allow(dead_code)] @@ -4593,6 +4824,7 @@ impl ChatWidget { skills: None, }), active_cell, + subagent_panel: None, active_cell_revision: 0, config, skills_all: Vec::new(), @@ -4654,7 +4886,199 @@ impl ChatWidget { show_welcome_banner: is_first_run, startup_tooltip_override, suppress_session_configured_redraw: false, +<<<<<<< HEAD suppress_initial_user_message_submit: false, +======= + pending_notification: None, + quit_shortcut_expires_at: None, + quit_shortcut_key: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + had_work_activity: false, + last_separator_elapsed_secs: None, + turn_runtime_metrics: RuntimeMetricsSummary::default(), + last_rendered_width: std::cell::Cell::new(None), + feedback, + feedback_audience, + current_rollout_path: None, + current_cwd, + session_network_proxy: None, + status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, + status_line_branch: None, + status_line_branch_cwd: None, + status_line_branch_pending: false, + status_line_branch_lookup_complete: false, + external_editor_state: ExternalEditorState::Closed, + realtime_conversation: RealtimeConversationUiState::default(), + last_replayed_agent_inbox_message: None, + last_rendered_user_message_event: None, + }; + + widget.prefetch_rate_limits(); + widget.bottom_pane.set_voice_transcription_enabled( + widget.config.features.enabled(Feature::VoiceTranscription), + ); + widget + .bottom_pane + .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); + widget + .bottom_pane + .set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled()); + widget + .bottom_pane + .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); + widget + .bottom_pane + .set_collaboration_modes_enabled(/*enabled*/ true); + widget.sync_fast_command_enabled(); + widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); + widget + .bottom_pane + .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget.refresh_terminal_title(); + + widget + } + + /// Create a ChatWidget attached to an existing conversation (e.g., a fork). + pub(crate) fn new_from_existing( + common: ChatWidgetInit, + conversation: std::sync::Arc, + session_configured: codex_protocol::protocol::SessionConfiguredEvent, + ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_user_message, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + is_first_run: _, + feedback_audience, + model, + startup_tooltip_override: _, + status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + session_telemetry, + } = common; + let model = model.filter(|m| !m.trim().is_empty()); + let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); + let mut rng = rand::rng(); + let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); + + let model_override = model.as_deref(); + let header_model = model + .clone() + .unwrap_or_else(|| session_configured.model.clone()); + let active_collaboration_mask = + Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); + let header_model = active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.clone()) + .unwrap_or(header_model); + + let current_cwd = Some(session_configured.cwd.clone()); + let codex_op_tx = + spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + + let fallback_default = Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }; + // Collaboration modes start in Default mode. + let current_collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: fallback_default, + }; + + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); + let mut widget = Self { + app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), + codex_op_tx, + bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + animations_enabled: config.animations, + skills: None, + }), + active_cell: None, + subagent_panel: None, + active_cell_revision: 0, + config, + skills_all: Vec::new(), + skills_initial_state: None, + current_collaboration_mode, + active_collaboration_mask, + auth_manager, + models_manager, + session_telemetry, + session_header: SessionHeader::new(header_model), + initial_user_message, + token_info: None, + rate_limit_snapshots_by_limit_id: BTreeMap::new(), + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + adaptive_chunking: AdaptiveChunkingPolicy::default(), + stream_controller: None, + plan_stream_controller: None, + last_copyable_output: None, + running_commands: HashMap::new(), + pending_collab_spawn_requests: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + unified_exec_wait_streak: None, + turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), + task_complete_pending: false, + unified_exec_processes: Vec::new(), + agent_turn_running: false, + mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), + connectors_partial_snapshot: None, + connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, + plugins_cache: PluginsCacheState::default(), + plugins_fetch_state: PluginListFetchState::default(), + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status: StatusIndicatorState::working(), + pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, + retry_status_header: None, + pending_status_indicator_restore: false, + suppress_queue_autosend: false, + thread_id: None, + thread_name: None, + forked_from: None, + queued_user_messages: VecDeque::new(), + pending_steers: VecDeque::new(), + submit_pending_steers_after_interrupt: false, + queued_message_edit_binding, + show_welcome_banner: false, + startup_tooltip_override: None, + suppress_session_configured_redraw: true, pending_notification: None, quit_shortcut_expires_at: None, quit_shortcut_key: None, @@ -10724,8 +11148,13 @@ impl ChatWidget { )), None => RenderableItem::Owned(Box::new(())), }; + let subagent_panel_renderable = match &self.subagent_panel { + Some(panel) => RenderableItem::Borrowed(panel).inset(Insets::tlbr(1, 0, 0, 0)), + None => RenderableItem::Owned(Box::new(())), + }; let mut flex = FlexRenderable::new(); flex.push(/*flex*/ 1, active_cell_renderable); + flex.push(0, subagent_panel_renderable); flex.push( /*flex*/ 0, RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr( diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__subagent_panel_mounts_while_placeholder_active_cell_exists.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__subagent_panel_mounts_while_placeholder_active_cell_exists.snap new file mode 100644 index 0000000000..d285b60137 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__subagent_panel_mounts_while_placeholder_active_cell_exists.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- +╭──────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: loading /model to change │ +│ directory: ~/code/codex-split/…/codex-rs/tui │ +╰──────────────────────────────────────────────╯ + +• Subagents (0s • no subagents running • esc to interrupt) +• [#1] [watchdog] watchdog-agent idle — watchdog idle + + +› show current subagent state + + 100% context left diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e4670ab2b5..b91109ee51 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2166,6 +2166,7 @@ async fn make_chatwidget_manual( codex_op_target: super::CodexOpTarget::Direct(op_tx), bottom_pane: bottom, active_cell: None, + subagent_panel: None, active_cell_revision: 0, config: cfg, current_collaboration_mode, @@ -2451,7 +2452,7 @@ async fn subagent_panel_is_not_flushed_into_transcript_history() { latest_update_at: Instant::now(), }], })); - chat.on_subagent_panel_updated(Arc::new(SubagentStatusCell::new(state, true))); + chat.on_subagent_panel_updated(Arc::new(SubagentStatusCell::new(Arc::clone(&state), true))); chat.add_to_history(history_cell::new_error_event("follow-up cell".to_string())); @@ -2464,11 +2465,66 @@ async fn subagent_panel_is_not_flushed_into_transcript_history() { let rendered = lines_to_single_string(&inserted[0]); assert!(rendered.contains("follow-up cell")); assert!(!rendered.contains("Subagents")); + assert!( + chat.subagent_panel + .as_ref() + .is_some_and(|panel| panel.matches_state(&state)), + "subagent panel should stay mounted after other history cells are inserted" + ); +} + +#[tokio::test] +async fn subagent_panel_mounts_while_placeholder_active_cell_exists_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.active_cell = Some(ChatWidget::placeholder_session_header_cell( + chat.config_ref(), + )); + chat.bottom_pane.set_composer_text( + "show current subagent state".to_string(), + Vec::new(), + Vec::new(), + ); + + let state = Arc::new(StdMutex::new(SubagentPanelState { + started_at: Instant::now(), + total_agents: 1, + running_count: 0, + running_agents: vec![SubagentPanelAgent { + ordinal: 1, + name: "watchdog-agent".to_string(), + status: AgentStatus::PendingInit, + is_watchdog: true, + preview: "watchdog idle".to_string(), + latest_update_at: Instant::now(), + }], + })); + chat.on_subagent_panel_updated(Arc::new(SubagentStatusCell::new(Arc::clone(&state), false))); + assert!( chat.active_cell .as_ref() - .is_some_and(|cell| cell.as_any().is::()), - "subagent panel should stay mounted after other history cells are inserted" + .is_some_and(|cell| cell.as_any().is::()), + "placeholder session header should remain the active cell" + ); + assert!( + chat.subagent_panel + .as_ref() + .is_some_and(|panel| panel.matches_state(&state)), + "subagent panel should mount even when another active cell already exists" + ); + + let width = 80; + let height = chat.desired_height(width); + let mut terminal = + ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("render chat with placeholder header and subagent panel"); + + assert_snapshot!( + "subagent_panel_mounts_while_placeholder_active_cell_exists", + terminal.backend().vt100().screen().contents() ); } fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8d4e02d5ef..55337860c3 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -566,9 +566,12 @@ impl SubagentStatusCell { impl HistoryCell for SubagentStatusCell { fn display_lines(&self, width: u16) -> Vec> { - let state = match self.state.lock() { - Ok(guard) => guard.clone(), - Err(poisoned) => poisoned.into_inner().clone(), + let state = { + let guard = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard.clone() }; if state.running_agents.is_empty() { return Vec::new(); @@ -618,10 +621,10 @@ impl HistoryCell for SubagentStatusCell { if !self.animations_enabled { return None; } - let guard = match self.state.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + let guard = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let now = Instant::now(); if !guard.has_animating_agents(now) { return None; @@ -630,7 +633,27 @@ impl HistoryCell for SubagentStatusCell { } } -#[allow(dead_code)] +impl Renderable for SubagentStatusCell { + fn render(&self, area: Rect, buf: &mut Buffer) { + let lines = self.display_lines(area.width); + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); + let y = if area.height == 0 { + 0 + } else { + let overflow = paragraph + .line_count(area.width) + .saturating_sub(usize::from(area.height)); + u16::try_from(overflow).unwrap_or(u16::MAX) + }; + paragraph.scroll((y, 0)).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + HistoryCell::desired_height(self, width) + } +} + +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn new_subagent_spawned_cell(name: &str, prompt_preview: &str) -> PlainHistoryCell { let mut lines = Vec::new(); lines.push(Line::from(vec![