diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index a000970ad7..9e934cdb04 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -25,8 +25,9 @@ impl App { .iter() .filter_map(|(name, server)| server.enabled.then_some(name.clone())) .collect(); - self.chat_widget - .set_mcp_startup_expected_servers(enabled_config_mcp_servers); + self.chat_widget.for_each_installed_mut(|pane| { + pane.set_mcp_startup_expected_servers(enabled_config_mcp_servers.iter().cloned()); + }); } pub(super) async fn handle_app_server_event( @@ -41,7 +42,8 @@ impl App { "app-server event consumer lagged; dropping ignored events" ); self.refresh_mcp_startup_expected_servers_from_config(); - self.chat_widget.finish_mcp_startup_after_lag(); + self.chat_widget + .for_each_installed_mut(|pane| pane.finish_mcp_startup_after_lag()); } AppServerEvent::ServerNotification(notification) => { self.handle_server_notification_event(app_server_client, notification) @@ -88,8 +90,9 @@ impl App { self.refresh_mcp_startup_expected_servers_from_config(); } ServerNotification::AccountRateLimitsUpdated(notification) => { - self.chat_widget - .on_rolling_rate_limit_snapshot(notification.rate_limits.clone()); + self.chat_widget.for_each_installed_mut(|pane| { + pane.on_rolling_rate_limit_snapshot(notification.rate_limits.clone()); + }); return; } ServerNotification::AccountUpdated(notification) => { @@ -102,17 +105,21 @@ impl App { | AuthMode::PersonalAccessToken ) ); - self.chat_widget.update_account_state( - status_account_display_from_auth_mode( - notification.auth_mode, - notification.plan_type, - ), + let status_account_display = status_account_display_from_auth_mode( + notification.auth_mode, notification.plan_type, - notification - .auth_mode - .is_some_and(AuthMode::has_chatgpt_account), - has_codex_backend_auth, ); + let has_chatgpt_account = notification + .auth_mode + .is_some_and(AuthMode::has_chatgpt_account); + self.chat_widget.for_each_installed_mut(|pane| { + pane.update_account_state( + status_account_display.clone(), + notification.plan_type, + has_chatgpt_account, + has_codex_backend_auth, + ); + }); return; } ServerNotification::ExternalAgentConfigImportCompleted(notification) => { @@ -125,7 +132,9 @@ impl App { ); } let cwd = self.chat_widget.config_ref().cwd.to_path_buf(); - self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.for_each_installed_mut(|pane| { + pane.refresh_plugin_mentions(); + }); self.chat_widget.submit_op(AppCommand::reload_user_config()); self.fetch_plugins_list(app_server_client, cwd); if should_report_completion { @@ -136,17 +145,17 @@ impl App { return; } ServerNotification::AppListUpdated(notification) => { - self.chat_widget.on_connectors_loaded( - Ok(ConnectorsSnapshot { - connectors: notification - .data - .iter() - .cloned() - .map(app_info_from_api) - .collect(), - }), - /*is_final*/ false, - ); + let snapshot = ConnectorsSnapshot { + connectors: notification + .data + .iter() + .cloned() + .map(app_info_from_api) + .collect(), + }; + self.chat_widget.for_each_installed_mut(|pane| { + pane.on_connectors_loaded(Ok(snapshot.clone()), /*is_final*/ false); + }); return; } _ => {} @@ -188,8 +197,9 @@ impl App { ServerNotificationThreadTarget::Global => {} } - self.chat_widget - .handle_server_notification(notification, /*replay_kind*/ None); + self.chat_widget.for_each_installed_mut(|pane| { + pane.handle_server_notification(notification.clone(), /*replay_kind*/ None); + }); } async fn handle_server_request_event( @@ -255,3 +265,7 @@ impl App { } } } + +#[cfg(test)] +#[path = "app_server_events_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/app_server_events_tests.rs b/codex-rs/tui/src/app/app_server_events_tests.rs new file mode 100644 index 0000000000..1da2a3281c --- /dev/null +++ b/codex-rs/tui/src/app/app_server_events_tests.rs @@ -0,0 +1,149 @@ +use super::*; +use crate::app::conversation_panes::ConversationPaneInit; +use crate::app::test_support::make_test_app_with_channels; +use crate::app_event::PaneSlot; +use crate::chatwidget::tests::constructor::make_chatwidget_for_pane_with_sender; +use crate::file_search::FileSearchManager; +use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::McpServerStartupState; +use codex_app_server_protocol::McpServerStatusUpdatedNotification; +use codex_protocol::account::PlanType; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::UnboundedReceiver; + +async fn install_test_side(app: &mut App) { + let side_widget = + make_chatwidget_for_pane_with_sender(PaneSlot::Side, app.app_event_tx.clone()).await; + let file_search = FileSearchManager::new( + side_widget.config_ref().cwd.to_path_buf(), + side_widget.conversation_event_sender(), + ); + assert!( + app.chat_widget + .install_side(ConversationPaneInit { + chat_widget: side_widget, + file_search, + owned_screen: None, + }) + .is_ok(), + "side pane should install" + ); +} + +fn take_history_messages(events: &mut UnboundedReceiver) -> Vec<(PaneSlot, String)> { + let mut messages = Vec::new(); + while let Ok(event) = events.try_recv() { + let (pane, event) = match event { + AppEvent::FromConversation { target, event } => (target.pane, *event), + event @ AppEvent::InsertHistoryCell(_) => (PaneSlot::Parent, event), + _ => continue, + }; + let AppEvent::InsertHistoryCell(cell) = event else { + continue; + }; + let text = cell + .display_lines(/*width*/ 80) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + messages.push((pane, text)); + } + messages +} + +#[tokio::test] +async fn account_and_global_notifications_reach_both_installed_panes() { + let (mut app, mut app_events, _op_rx) = make_test_app_with_channels().await; + install_test_side(&mut app).await; + let app_server = crate::start_embedded_app_server_for_picker(&app.config) + .await + .expect("embedded app server should start"); + + app.handle_server_notification_event( + &app_server, + ServerNotification::AccountUpdated(AccountUpdatedNotification { + auth_mode: Some(AuthMode::Chatgpt), + plan_type: Some(PlanType::Plus), + }), + ) + .await; + + for slot in [PaneSlot::Parent, PaneSlot::Side] { + let pane = app.chat_widget.by_slot(slot).expect("installed pane"); + assert_eq!(pane.current_plan_type(), Some(PlanType::Plus)); + assert!(pane.has_chatgpt_account()); + assert!(pane.has_codex_backend_auth()); + } + + while app_events.try_recv().is_ok() {} + app.handle_server_notification_event( + &app_server, + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "shared warning".to_string(), + details: None, + path: None, + range: None, + }), + ) + .await; + + assert_eq!( + take_history_messages(&mut app_events), + vec![ + (PaneSlot::Parent, "⚠ shared warning".to_string()), + (PaneSlot::Side, "⚠ shared warning".to_string()), + ] + ); +} + +#[tokio::test] +async fn lag_finishes_mcp_startup_in_both_installed_panes() { + let (mut app, mut app_events, _op_rx) = make_test_app_with_channels().await; + install_test_side(&mut app).await; + let sentry_config = toml::from_str::("command = 'true'") + .expect("test MCP config should parse") + .try_into() + .expect("test MCP config should deserialize"); + app.config + .mcp_servers + .set(std::collections::HashMap::from([( + "sentry".to_string(), + sentry_config, + )])) + .expect("test MCP servers should accept any configuration"); + let app_server = crate::start_embedded_app_server_for_picker(&app.config) + .await + .expect("embedded app server should start"); + let starting = ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: None, + name: "alpha".to_string(), + status: McpServerStartupState::Starting, + error: None, + failure_reason: None, + }); + app.chat_widget.for_each_installed_mut(|pane| { + pane.handle_server_notification(starting.clone(), /*replay_kind*/ None); + }); + while app_events.try_recv().is_ok() {} + + app.handle_app_server_event(&app_server, AppServerEvent::Lagged { skipped: 1 }) + .await; + + let warnings = take_history_messages(&mut app_events); + assert_eq!(warnings.len(), 2); + assert_eq!(warnings[0].0, PaneSlot::Parent); + assert_eq!(warnings[1].0, PaneSlot::Side); + assert!( + warnings + .iter() + .all(|(_, text)| text.contains("alpha") && text.contains("sentry")), + "each pane should report both observed and configured MCP servers: {warnings:?}" + ); +} diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 80ed84d01e..9a831f69e8 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -211,7 +211,10 @@ impl App { .await?; self.apply_runtime_policy_overrides(&mut config); self.config = config; - self.chat_widget.sync_plugin_mentions_config(&self.config); + let config = &self.config; + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.sync_plugin_mentions_config(config); + }); Ok(()) } @@ -530,8 +533,10 @@ impl App { *feature == Feature::MemoryTool && *enabled && !memory_tool_was_enabled }); for (feature, effective_enabled) in feature_updates_to_apply { - self.chat_widget - .set_feature_enabled(feature, effective_enabled); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget + .set_feature_enabled(feature, effective_enabled); + }); } if show_memory_enable_notice { self.chat_widget.add_memories_enable_notice(); @@ -658,8 +663,10 @@ impl App { self.config.memories.use_memories = use_memories; self.config.memories.generate_memories = generate_memories; - self.chat_widget - .set_memory_settings(use_memories, generate_memories); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget + .set_memory_settings(use_memories, generate_memories); + }); true } @@ -669,7 +676,6 @@ impl App { use_memories: bool, generate_memories: bool, ) { - let previous_generate_memories = self.config.memories.generate_memories; if !self .update_memory_settings(app_server, use_memories, generate_memories) .await @@ -678,25 +684,28 @@ impl App { } let generate_memories = self.config.memories.generate_memories; - if previous_generate_memories == generate_memories { - return; - } - - let Some(thread_id) = self.current_displayed_thread_id() else { - return; - }; - let mode = if generate_memories { ThreadMemoryMode::Enabled } else { ThreadMemoryMode::Disabled }; + let thread_ids: Vec<_> = self + .chat_widget + .installed_thread_ids() + .into_iter() + .filter(|thread_id| !self.is_thread_retired(thread_id)) + .collect(); - if let Err(err) = app_server.thread_memory_mode_set(thread_id, mode).await { - tracing::error!(error = %err, %thread_id, "failed to update thread memory mode"); - self.chat_widget.add_error_message(format!( - "Saved memory settings, but failed to update the current thread: {err}" - )); + for thread_id in thread_ids { + if let Err(err) = app_server + .thread_memory_mode_set(thread_id, mode.clone()) + .await + { + tracing::error!(error = %err, %thread_id, "failed to update thread memory mode"); + self.chat_widget.add_error_message(format!( + "Saved memory settings, but failed to update thread {thread_id}: {err}" + )); + } } } @@ -747,19 +756,25 @@ impl App { pub(super) fn sync_tui_theme_selection(&mut self, name: String) { self.config.tui_theme = Some(name.clone()); - self.chat_widget.set_tui_theme(Some(name)); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.set_tui_theme(Some(name.clone())); + }); } #[cfg(test)] pub(super) fn sync_tui_pet_selection(&mut self, pet: String) { self.config.tui_pet = Some(pet.clone()); - self.chat_widget.set_tui_pet(Some(pet)); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.set_tui_pet(Some(pet.clone())); + }); } pub(super) fn sync_tui_pet_disabled(&mut self) { let pet = crate::pets::DISABLED_PET_ID.to_string(); self.config.tui_pet = Some(pet.clone()); - self.chat_widget.set_tui_pet(Some(pet)); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.set_tui_pet(Some(pet.clone())); + }); } pub(super) fn restore_runtime_theme_from_config(&self) { @@ -803,7 +818,9 @@ impl App { ); continue; } - self.chat_widget.set_feature_enabled(*feature, enabled); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.set_feature_enabled(*feature, enabled); + }); } if feature_updates @@ -928,8 +945,10 @@ impl App { .unwrap_or(self.config.memories.generate_memories); self.config.memories.use_memories = use_memories; self.config.memories.generate_memories = generate_memories; - self.chat_widget - .set_memory_settings(use_memories, generate_memories); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget + .set_memory_settings(use_memories, generate_memories); + }); true } @@ -1062,12 +1081,27 @@ mod tests { use super::*; use crate::app::test_support::app_enabled_in_effective_config; use crate::app::test_support::make_test_app; + use crate::chatwidget::tests::constructor::make_chatwidget_for_pane; use crate::legacy_core::config::edit::ConfigEdit; use crate::test_support::PathBufExt; use codex_protocol::models::PermissionProfile; use pretty_assertions::assert_eq; use tempfile::tempdir; + async fn install_test_side(app: &mut App) { + let (side_widget, _side_rx) = make_chatwidget_for_pane(PaneSlot::Side).await; + let file_search = FileSearchManager::new( + side_widget.config_ref().cwd.to_path_buf(), + side_widget.conversation_event_sender(), + ); + let installed = app.chat_widget.install_side(ConversationPaneInit { + chat_widget: side_widget, + file_search, + owned_screen: None, + }); + assert!(installed.is_ok(), "side pane should install"); + } + #[tokio::test] async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; @@ -1342,32 +1376,49 @@ terminal_resize_reflow_max_rows = 9000 #[tokio::test] async fn sync_tui_theme_selection_updates_chat_widget_config_copy() { let mut app = make_test_app().await; + install_test_side(&mut app).await; app.sync_tui_theme_selection("dracula".to_string()); assert_eq!(app.config.tui_theme.as_deref(), Some("dracula")); - assert_eq!( - app.chat_widget.config_ref().tui_theme.as_deref(), - Some("dracula") - ); + for slot in [PaneSlot::Parent, PaneSlot::Side] { + assert_eq!( + app.chat_widget + .by_slot(slot) + .expect("installed pane") + .config_ref() + .tui_theme + .as_deref(), + Some("dracula") + ); + } } #[tokio::test] async fn sync_tui_pet_selection_updates_chat_widget_config_copy() { let mut app = make_test_app().await; + install_test_side(&mut app).await; app.sync_tui_pet_selection("chefito".to_string()); assert_eq!(app.config.tui_pet.as_deref(), Some("chefito")); - assert_eq!( - app.chat_widget.config_ref().tui_pet.as_deref(), - Some("chefito") - ); + for slot in [PaneSlot::Parent, PaneSlot::Side] { + assert_eq!( + app.chat_widget + .by_slot(slot) + .expect("installed pane") + .config_ref() + .tui_pet + .as_deref(), + Some("chefito") + ); + } } #[tokio::test] async fn sync_tui_pet_disabled_updates_chat_widget_config_copy() { let mut app = make_test_app().await; + install_test_side(&mut app).await; app.sync_tui_pet_disabled(); @@ -1375,9 +1426,16 @@ terminal_resize_reflow_max_rows = 9000 app.config.tui_pet.as_deref(), Some(crate::pets::DISABLED_PET_ID) ); - assert_eq!( - app.chat_widget.config_ref().tui_pet.as_deref(), - Some(crate::pets::DISABLED_PET_ID) - ); + for slot in [PaneSlot::Parent, PaneSlot::Side] { + assert_eq!( + app.chat_widget + .by_slot(slot) + .expect("installed pane") + .config_ref() + .tui_pet + .as_deref(), + Some(crate::pets::DISABLED_PET_ID) + ); + } } } diff --git a/codex-rs/tui/src/app/conversation_panes.rs b/codex-rs/tui/src/app/conversation_panes.rs index 3beab543ad..d4d0413fdd 100644 --- a/codex-rs/tui/src/app/conversation_panes.rs +++ b/codex-rs/tui/src/app/conversation_panes.rs @@ -233,6 +233,13 @@ impl ConversationPanes { thread_ids } + pub(super) fn for_each_installed_mut(&mut self, mut visit: impl FnMut(&mut ConversationPane)) { + visit(&mut self.parent); + if let Some(side) = &mut self.side { + visit(side); + } + } + pub(super) fn dispatch_to(&mut self, origin: ConversationOrigin) -> bool { if self.by_origin(origin).is_none() { return false; diff --git a/codex-rs/tui/src/app/conversation_panes_tests.rs b/codex-rs/tui/src/app/conversation_panes_tests.rs index e3737771b0..1a2a4d1ab2 100644 --- a/codex-rs/tui/src/app/conversation_panes_tests.rs +++ b/codex-rs/tui/src/app/conversation_panes_tests.rs @@ -127,6 +127,44 @@ async fn installed_thread_ids_are_parent_first_and_unique() { assert_eq!(panes.installed_thread_ids(), vec![parent_thread_id]); } +#[tokio::test] +async fn for_each_installed_mut_visits_parent_then_side_without_changing_selection() { + let (parent, _parent_rx) = pane_init(PaneSlot::Parent).await; + let (side, _side_rx) = pane_init(PaneSlot::Side).await; + let Ok(mut panes) = ConversationPanes::new_parent(parent) else { + panic!("parent pane should install"); + }; + assert!(panes.install_side(side).is_ok()); + assert!(panes.focus(PaneSlot::Side)); + let parent_origin = panes + .by_slot(PaneSlot::Parent) + .and_then(ConversationPane::origin) + .expect("parent origin"); + assert!(panes.dispatch_to(parent_origin)); + let mut visited = Vec::new(); + + panes.for_each_installed_mut(|pane| visited.push(pane.slot)); + + assert_eq!(visited, vec![PaneSlot::Parent, PaneSlot::Side]); + assert_eq!(panes.focused, PaneSlot::Side); + assert_eq!(panes.dispatch, Some(PaneSlot::Parent)); +} + +#[tokio::test] +async fn for_each_installed_mut_visits_only_parent_without_a_side_pane() { + let (parent, _parent_rx) = pane_init(PaneSlot::Parent).await; + let Ok(mut panes) = ConversationPanes::new_parent(parent) else { + panic!("parent pane should install"); + }; + let mut visited = Vec::new(); + + panes.for_each_installed_mut(|pane| visited.push(pane.slot)); + + assert_eq!(visited, vec![PaneSlot::Parent]); + assert_eq!(panes.focused, PaneSlot::Parent); + assert_eq!(panes.dispatch, None); +} + #[tokio::test] async fn a_closed_receiver_does_not_starve_the_other_pane() { let (parent, _parent_app_rx) = pane_init(PaneSlot::Parent).await; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index f5fe7bff12..6e41c554dc 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1861,18 +1861,33 @@ impl App { self.windows_sandbox.skip_world_writable_scan_once = true; } AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { - self.chat_widget.set_full_access_warning_acknowledged(ack); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.set_full_access_warning_acknowledged(ack); + }); } AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => { - self.chat_widget - .set_world_writable_warning_acknowledged(ack); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget + .set_world_writable_warning_acknowledged(ack); + }); } AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { - self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); + }); } AppEvent::UpdatePlanModeReasoningEffort(effort) => { self.config.plan_mode_reasoning_effort = effort.clone(); - self.chat_widget.set_plan_mode_reasoning_effort(effort); + let selected_origin = self.chat_widget.origin(); + self.chat_widget.for_each_installed_mut(|pane| { + if pane.origin() == selected_origin { + pane.chat_widget + .set_plan_mode_reasoning_effort(effort.clone()); + } else { + pane.chat_widget + .sync_plan_mode_reasoning_effort_config(effort.clone()); + } + }); self.sync_active_thread_plan_mode_reasoning_setting(app_server) .await; } @@ -1997,7 +2012,9 @@ impl App { .await { Ok(()) => { - self.chat_widget.update_skill_enabled(path, enabled); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.update_skill_enabled(path.clone(), enabled); + }); } Err(err) => { let path_display = path.display(); @@ -2033,7 +2050,9 @@ impl App { .await { Ok(_) => { - self.chat_widget.update_connector_enabled(&id, enabled); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.update_connector_enabled(&id, enabled); + }); } Err(err) => { self.chat_widget.add_error_message(format!( @@ -2190,7 +2209,10 @@ impl App { Ok(()) => { self.config.tui_status_line = Some(ids.clone()); self.config.tui_status_line_use_colors = use_theme_colors; - self.chat_widget.setup_status_line(items, use_theme_colors); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget + .setup_status_line(items.clone(), use_theme_colors); + }); } Err(err) => { let error = format_config_error(&err); @@ -2266,12 +2288,16 @@ impl App { crate::render::highlight::set_syntax_theme(theme); } self.sync_tui_theme_selection(name); - self.refresh_status_line(); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.refresh_status_line(); + }); tui.frame_requester().schedule_frame(); } Err(err) => { self.restore_runtime_theme_from_config(); - self.refresh_status_line(); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.refresh_status_line(); + }); tracing::error!(error = %err, "failed to persist theme selection"); self.chat_widget .add_error_message(format!("Failed to save theme: {err}")); @@ -2279,7 +2305,9 @@ impl App { } } AppEvent::SyntaxThemePreviewed => { - self.refresh_status_line(); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.refresh_status_line(); + }); tui.frame_requester().schedule_frame(); } AppEvent::OpenKeymapActionMenu { context, action } => { @@ -2386,7 +2414,9 @@ impl App { } fn refresh_plugin_mentions_after_config_write(&mut self) { - self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.refresh_plugin_mentions(); + }); self.chat_widget.submit_op(AppCommand::reload_user_config()); } diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index c20b9a1544..db5b26ea61 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -78,9 +78,10 @@ impl App { ) { if notify { self.chat_widget.set_raw_output_mode_and_notify(enabled); - } else { - self.chat_widget.set_raw_output_mode(enabled); } + self.chat_widget.for_each_installed_mut(|pane| { + pane.chat_widget.set_raw_output_mode(enabled); + }); self.sync_owned_screen_render_mode(); if self.has_owned_screen() { tui.frame_requester().schedule_frame(); diff --git a/codex-rs/tui/src/app/owned_screen.rs b/codex-rs/tui/src/app/owned_screen.rs index 92a951fe1b..7d1577648e 100644 --- a/codex-rs/tui/src/app/owned_screen.rs +++ b/codex-rs/tui/src/app/owned_screen.rs @@ -392,10 +392,12 @@ impl App { } pub(super) fn sync_owned_screen_render_mode(&mut self) { - let render_mode = self.chat_widget.history_render_mode(); - if let Some(screen) = &mut self.chat_widget.owned_screen { - screen.viewport.set_render_mode(render_mode); - } + self.chat_widget.for_each_installed_mut(|pane| { + let render_mode = pane.chat_widget.history_render_mode(); + if let Some(screen) = &mut pane.owned_screen { + screen.viewport.set_render_mode(render_mode); + } + }); } pub(super) fn handle_owned_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result { diff --git a/codex-rs/tui/src/app/owned_screen_tests.rs b/codex-rs/tui/src/app/owned_screen_tests.rs index 74b21413fe..f44df5bcc2 100644 --- a/codex-rs/tui/src/app/owned_screen_tests.rs +++ b/codex-rs/tui/src/app/owned_screen_tests.rs @@ -14,10 +14,12 @@ use super::super::conversation_panes::ConversationPaneInit; use super::*; use crate::app_event::PaneSlot; use crate::chatwidget::tests::constructor::make_chatwidget_for_pane; +use crate::chatwidget::tests::constructor::make_chatwidget_for_pane_with_sender; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::file_search::FileSearchManager; use crate::tui::MouseScrollDirection; use crate::tui::MouseScrollEvent; +use codex_app_server_protocol::ConfigWarningNotification; #[derive(Debug)] struct TestCell(&'static str); @@ -32,6 +34,22 @@ impl HistoryCell for TestCell { } } +#[derive(Debug)] +struct RenderModeCell { + display: &'static str, + raw: &'static str, +} + +impl HistoryCell for RenderModeCell { + fn display_lines(&self, _width: u16) -> Vec> { + vec![self.display.into()] + } + + fn raw_lines(&self) -> Vec> { + vec![self.raw.into()] + } +} + async fn app_with_owned_parent() -> App { let mut app = super::super::test_support::make_test_app().await; app.chat_widget.owned_screen = App::owned_screen_for_behavior( @@ -202,6 +220,129 @@ async fn renders_wide_parent_left_and_side_right() { ); } +#[tokio::test] +async fn raw_output_mode_fans_out_without_changing_focus_or_drafts() { + let mut app = app_with_owned_side().await; + for (slot, draft, display, raw) in [ + ( + PaneSlot::Parent, + "parent draft", + "parent rich transcript", + "parent raw transcript", + ), + ( + PaneSlot::Side, + "side draft", + "side rich transcript", + "side raw transcript", + ), + ] { + let pane = app.chat_widget.by_slot_mut(slot).expect("installed pane"); + pane.chat_widget + .set_composer_text(draft.to_string(), Vec::new(), Vec::new()); + pane.owned_screen + .as_mut() + .expect("owned screen") + .viewport + .push_cell(Arc::new(RenderModeCell { display, raw })); + } + assert!(app.chat_widget.focus(PaneSlot::Side)); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI"); + + app.apply_raw_output_mode(&mut tui, /*enabled*/ true, /*notify*/ false); + + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side); + for (slot, draft) in [ + (PaneSlot::Parent, "parent draft"), + (PaneSlot::Side, "side draft"), + ] { + let pane = app.chat_widget.by_slot(slot).expect("installed pane"); + assert!(pane.chat_widget.raw_output_mode()); + assert_eq!(pane.chat_widget.composer_text_with_pending(), draft); + } + let terminal = render_app(&mut app, /*width*/ 83, /*height*/ 12); + assert_snapshot!("owned_screen_raw_output_mode_fans_out", terminal.backend()); +} + +#[tokio::test] +async fn global_warning_renders_in_both_owned_panes() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = + super::super::test_support::make_test_app_with_channels().await; + app.chat_widget.owned_screen = App::owned_screen_for_behavior( + AltScreenBehavior::Owned, + &app.chat_widget, + app.keymap.pager.clone(), + ); + let side_widget = + make_chatwidget_for_pane_with_sender(PaneSlot::Side, app.app_event_tx.clone()).await; + let file_search = FileSearchManager::new( + side_widget.config_ref().cwd.to_path_buf(), + side_widget.conversation_event_sender(), + ); + let owned_screen = App::owned_screen_for_behavior( + AltScreenBehavior::Owned, + &side_widget, + app.keymap.pager.clone(), + ); + assert!( + app.chat_widget + .install_side(ConversationPaneInit { + chat_widget: side_widget, + file_search, + owned_screen, + }) + .is_ok(), + "side pane should install" + ); + for (slot, draft) in [ + (PaneSlot::Parent, "parent draft"), + (PaneSlot::Side, "side draft"), + ] { + app.chat_widget + .by_slot_mut(slot) + .expect("installed pane") + .set_composer_text(draft.to_string(), Vec::new(), Vec::new()); + } + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "Shared configuration warning".to_string(), + details: None, + path: None, + range: None, + }), + ), + ) + .await; + let mut tui = crate::tui::test_support::make_test_tui()?; + while let Ok(event) = app_event_rx.try_recv() { + app.handle_event(&mut tui, &mut app_server, event).await?; + } + assert_eq!( + app.chat_widget + .by_slot(PaneSlot::Parent) + .expect("parent pane") + .transcript_cells + .len(), + 1 + ); + assert_eq!( + app.chat_widget + .by_slot(PaneSlot::Side) + .expect("side pane") + .transcript_cells + .len(), + 1 + ); + + let terminal = render_app(&mut app, /*width*/ 83, /*height*/ 18); + + assert_snapshot!("owned_screen_global_warning_fans_out", terminal.backend()); + Ok(()) +} + #[tokio::test] async fn renders_closed_parent_read_only_while_side_remains_focused() { let mut app = app_with_owned_side().await; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_global_warning_fans_out.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_global_warning_fans_out.snap new file mode 100644 index 0000000000..4ea4ad6878 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_global_warning_fans_out.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/app/owned_screen_tests.rs +expression: terminal.backend() +--- +" Parent ⌥ + 1 / ⌥ + 2 focus │ Side " +"⚠ Shared configuration warning │⚠ Shared configuration warning " +" │ " +" │╭───────────────────────────────────────╮" +" ││ >_ OpenAI Codex (v0.0.0) │" +" ││ │" +" ││ model: loading /model to change │" +" ││ directory: /tmp/project │" +" │╰───────────────────────────────────────╯" +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +"› parent draft │› side draft " +" │ " +" gpt-5.5 default · /tmp/project │ gpt-5.5 default · /tmp/project " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_raw_output_mode_fans_out.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_raw_output_mode_fans_out.snap new file mode 100644 index 0000000000..3aa320f28c --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_raw_output_mode_fans_out.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/app/owned_screen_tests.rs +expression: terminal.backend() +--- +" Parent │ Side ⌥ + 1 / ⌥ + 2 focus " +"parent raw transcript │side raw transcript " +" │ " +" │OpenAI Codex (v0.0.0) " +" │model: loading " +" │directory: /tmp/project " +" │ " +" │ " +" │ " +"› parent draft │› side draft " +" │ " +" gpt-5.5 default · /tmp/project │ gpt-5.5 default · /tmp/project " diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 475b78ed59..afd35b5c45 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1682,9 +1682,62 @@ async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Resu Ok(()) } +#[tokio::test] +async fn update_plan_mode_reasoning_effort_updates_both_widget_configs() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let parent_thread_id = app_server + .start_thread(&app.config) + .await? + .session + .thread_id; + let side_thread_id = app_server + .start_thread(&app.config) + .await? + .session + .thread_id; + install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await; + let side_origin = app + .chat_widget + .by_slot(PaneSlot::Side) + .and_then(super::conversation_panes::ConversationPane::origin) + .expect("side origin"); + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::FromConversation { + target: side_origin, + event: Box::new(AppEvent::UpdatePlanModeReasoningEffort(Some( + ReasoningEffortConfig::High, + ))), + }, + ) + .await?; + + assert_eq!( + app.config.plan_mode_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + for slot in [PaneSlot::Parent, PaneSlot::Side] { + assert_eq!( + app.chat_widget + .by_slot(slot) + .expect("installed pane") + .config_ref() + .plan_mode_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + } + app_server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn update_memory_settings_persists_and_updates_widget_config() -> Result<()> { let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; + install_test_side_pane(&mut app, ThreadId::new(), ThreadId::new()).await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; @@ -1698,8 +1751,15 @@ async fn update_memory_settings_persists_and_updates_widget_config() -> Result<( assert!(!app.config.memories.use_memories); assert!(!app.config.memories.generate_memories); - assert!(!app.chat_widget.config_ref().memories.use_memories); - assert!(!app.chat_widget.config_ref().memories.generate_memories); + for slot in [PaneSlot::Parent, PaneSlot::Side] { + let config = app + .chat_widget + .by_slot(slot) + .expect("installed pane") + .config_ref(); + assert!(!config.memories.use_memories); + assert!(!config.memories.generate_memories); + } let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; let config_value = toml::from_str::(&config)?; @@ -1726,7 +1786,7 @@ async fn update_memory_settings_persists_and_updates_widget_config() -> Result<( } #[test] -fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> { +fn update_memory_settings_updates_all_installed_thread_memory_modes() -> Result<()> { const WORKER_THREADS: usize = 1; const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; @@ -1746,9 +1806,17 @@ fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> { let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; - let started = app_server.start_thread(&app.config).await?; - let thread_id = started.session.thread_id; - app.chat_widget.active_thread_id = Some(thread_id); + let parent_thread_id = app_server + .start_thread(&app.config) + .await? + .session + .thread_id; + let side_thread_id = app_server + .start_thread(&app.config) + .await? + .session + .thread_id; + install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await; Box::pin(app.update_memory_settings_with_app_server( &mut app_server, @@ -1763,11 +1831,13 @@ fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> { ) .await .expect("state db should initialize"); - let memory_mode = state_db - .get_thread_memory_mode(thread_id) - .await - .expect("thread memory mode should be readable"); - assert_eq!(memory_mode.as_deref(), Some("disabled")); + for thread_id in [parent_thread_id, side_thread_id] { + let memory_mode = state_db + .get_thread_memory_mode(thread_id) + .await + .expect("thread memory mode should be readable"); + assert_eq!(memory_mode.as_deref(), Some("disabled")); + } app_server.shutdown().await?; Ok(()) diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 269606098f..acebe2b773 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -144,7 +144,7 @@ impl ChatWidget { /// so the footer reflects it without waiting for the next mode switch. /// Passing `None` resets to the Plan-mode preset default. pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { - self.config.plan_mode_reasoning_effort = effort.clone(); + self.sync_plan_mode_reasoning_effort_config(effort.clone()); if self.collaboration_modes_enabled() && let Some(mask) = self.active_collaboration_mask.as_mut() && mask.mode == Some(ModeKind::Plan) @@ -160,6 +160,13 @@ impl ChatWidget { self.refresh_model_dependent_surfaces(); } + pub(crate) fn sync_plan_mode_reasoning_effort_config( + &mut self, + effort: Option, + ) { + self.config.plan_mode_reasoning_effort = effort; + } + /// Set the reasoning effort for the non-Plan collaboration mode. /// /// Does not touch the active Plan mask — Plan reasoning is controlled