diff --git a/code-rs/tui/src/bottom_pane/model_selection_view.rs b/code-rs/tui/src/bottom_pane/model_selection_view.rs index fff7b8dc68f..eab099cb411 100644 --- a/code-rs/tui/src/bottom_pane/model_selection_view.rs +++ b/code-rs/tui/src/bottom_pane/model_selection_view.rs @@ -144,6 +144,11 @@ pub(crate) struct ModelSelectionView { current_effort: ReasoningEffort, current_service_tier: Option, current_context_mode: Option, + baseline_service_tier: Option, + baseline_context_mode: Option, + defer_session_mode_toggles_until_close: bool, + staged_service_tier: Option>, + staged_context_mode: Option>, use_chat_model: bool, app_event_tx: AppEventSender, is_complete: bool, @@ -191,6 +196,11 @@ impl ModelSelectionView { current_effort, current_service_tier, current_context_mode, + baseline_service_tier: current_service_tier, + baseline_context_mode: current_context_mode, + defer_session_mode_toggles_until_close: false, + staged_service_tier: None, + staged_context_mode: None, use_chat_model, app_event_tx, is_complete: false, @@ -198,6 +208,35 @@ impl ModelSelectionView { } } + pub(crate) fn defer_session_mode_toggles_until_close(&mut self) { + self.defer_session_mode_toggles_until_close = true; + } + + pub(crate) fn flush_deferred_session_updates(&mut self) { + if let Some(service_tier) = self.staged_service_tier.take() { + if service_tier != self.baseline_service_tier { + let _ = self + .app_event_tx + .send(AppEvent::UpdateServiceTierSelection { service_tier }); + self.baseline_service_tier = service_tier; + } + } + + if let Some(context_mode) = self.staged_context_mode.take() { + if context_mode != self.baseline_context_mode { + let _ = self + .app_event_tx + .send(AppEvent::UpdateSessionContextModeSelection { context_mode }); + self.baseline_context_mode = context_mode; + } + } + } + + fn should_defer_session_mode_toggles(&self) -> bool { + self.defer_session_mode_toggles_until_close + && matches!(self.target, ModelSelectionTarget::Session) + } + pub(crate) fn update_presets(&mut self, presets: Vec) { let include_fast_mode = self.target.supports_fast_mode(); let include_context_mode = self.target.supports_fast_mode(); @@ -439,9 +478,13 @@ impl ModelSelectionView { Some(ServiceTier::Fast) }; self.current_service_tier = next_service_tier; - let _ = self.app_event_tx.send(AppEvent::UpdateServiceTierSelection { - service_tier: next_service_tier, - }); + if self.should_defer_session_mode_toggles() { + self.staged_service_tier = Some(next_service_tier); + } else { + let _ = self.app_event_tx.send(AppEvent::UpdateServiceTierSelection { + service_tier: next_service_tier, + }); + } return; } EntryKind::ContextMode => { @@ -451,9 +494,13 @@ impl ModelSelectionView { Some(ContextMode::Auto) => Some(ContextMode::Disabled), }; self.current_context_mode = next_context_mode; - let _ = self.app_event_tx.send(AppEvent::UpdateSessionContextModeSelection { - context_mode: next_context_mode, - }); + if self.should_defer_session_mode_toggles() { + self.staged_context_mode = Some(next_context_mode); + } else { + let _ = self.app_event_tx.send(AppEvent::UpdateSessionContextModeSelection { + context_mode: next_context_mode, + }); + } return; } EntryKind::FollowChat => { @@ -1387,6 +1434,97 @@ mod tests { assert!(!view.is_complete()); } + #[test] + fn deferred_context_mode_waits_until_close() { + let presets = vec![make_preset("gpt-5.4")]; + let (tx, rx) = mpsc::channel::(); + let mut view = ModelSelectionView::new( + presets, + "gpt-5.4".to_string(), + ReasoningEffort::Low, + None, + Some(ContextMode::Auto), + false, + ModelSelectionTarget::Session, + AppEventSender::new(tx), + ); + view.defer_session_mode_toggles_until_close(); + + let _ = view.handle_key_event_direct(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let _ = view.handle_key_event_direct(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(rx.try_recv().is_err(), "event should wait until close"); + + view.flush_deferred_session_updates(); + + let event = rx.try_recv().expect("context mode event after close"); + assert!(matches!( + event, + AppEvent::UpdateSessionContextModeSelection { + context_mode: Some(ContextMode::Disabled) + } + )); + assert!(!view.is_complete()); + } + + #[test] + fn deferred_fast_mode_waits_until_close() { + let presets = vec![make_preset("gpt-5.4")]; + let (tx, rx) = mpsc::channel::(); + let mut view = ModelSelectionView::new( + presets, + "gpt-5.4".to_string(), + ReasoningEffort::Low, + None, + None, + false, + ModelSelectionTarget::Session, + AppEventSender::new(tx), + ); + view.defer_session_mode_toggles_until_close(); + + let _ = view.handle_key_event_direct(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(rx.try_recv().is_err(), "event should wait until close"); + + view.flush_deferred_session_updates(); + + let event = rx.try_recv().expect("service tier event after close"); + assert!(matches!( + event, + AppEvent::UpdateServiceTierSelection { + service_tier: Some(ServiceTier::Fast) + } + )); + assert!(!view.is_complete()); + } + + #[test] + fn deferred_context_mode_skips_event_when_restored_before_close() { + let presets = vec![make_preset("gpt-5.4")]; + let (tx, rx) = mpsc::channel::(); + let mut view = ModelSelectionView::new( + presets, + "gpt-5.4".to_string(), + ReasoningEffort::Low, + None, + Some(ContextMode::Auto), + false, + ModelSelectionTarget::Session, + AppEventSender::new(tx), + ); + view.defer_session_mode_toggles_until_close(); + + let _ = view.handle_key_event_direct(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let _ = view.handle_key_event_direct(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let _ = view.handle_key_event_direct(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let _ = view.handle_key_event_direct(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + view.flush_deferred_session_updates(); + + assert!(rx.try_recv().is_err(), "restoring the original mode should emit nothing"); + } + #[test] fn model_selection_shows_unavailable_context_hint_for_unsupported_model() { let presets = vec![make_preset("gpt-5.3-codex")]; diff --git a/code-rs/tui/src/chatwidget/settings_overlay.rs b/code-rs/tui/src/chatwidget/settings_overlay.rs index b4aea76d077..df9977c8150 100644 --- a/code-rs/tui/src/chatwidget/settings_overlay.rs +++ b/code-rs/tui/src/chatwidget/settings_overlay.rs @@ -176,6 +176,8 @@ pub(crate) struct ModelSettingsContent { impl ModelSettingsContent { pub(crate) fn new(view: ModelSelectionView) -> Self { + let mut view = view; + view.defer_session_mode_toggles_until_close(); Self { view } } } @@ -192,6 +194,10 @@ impl SettingsContent for ModelSettingsContent { fn is_complete(&self) -> bool { self.view.is_complete() } + + fn on_close(&mut self) { + self.view.flush_deferred_session_updates(); + } } pub(crate) struct ThemeSettingsContent { @@ -2442,33 +2448,78 @@ impl SettingsOverlayView { } pub(crate) fn notify_close(&mut self) { - match self.active_section() { - SettingsSection::Model => { - if let Some(content) = self.model_content.as_mut() { - content.on_close(); - } - } - SettingsSection::Theme => { - if let Some(content) = self.theme_content.as_mut() { - content.on_close(); - } - } - SettingsSection::Notifications => { - if let Some(content) = self.notifications_content.as_mut() { - content.on_close(); - } - } - SettingsSection::Mcp => { - if let Some(content) = self.mcp_content.as_mut() { - content.on_close(); - } - } - SettingsSection::Chrome => { - if let Some(content) = self.chrome_content.as_mut() { - content.on_close(); - } - } - _ => {} + if let Some(content) = self.model_content.as_mut() { + content.on_close(); + } + + if let Some(content) = self.theme_content.as_mut() { + content.on_close(); + } + + if let Some(content) = self.notifications_content.as_mut() { + content.on_close(); + } + + if let Some(content) = self.mcp_content.as_mut() { + content.on_close(); } + + if let Some(content) = self.chrome_content.as_mut() { + content.on_close(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use code_common::model_presets::builtin_model_presets; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::sync::mpsc; + + #[test] + fn overlay_notify_close_flushes_deferred_model_updates_after_section_change() { + let presets = builtin_model_presets(None, false); + let current_model = presets + .first() + .expect("at least one builtin model preset") + .model + .clone(); + + let (tx, rx) = mpsc::channel::(); + let app_event_tx = crate::app_event_sender::AppEventSender::new(tx); + + let view = ModelSelectionView::new( + presets, + current_model, + ReasoningEffort::Low, + None, + None, + false, + crate::bottom_pane::ModelSelectionTarget::Session, + app_event_tx, + ); + + let mut content = ModelSettingsContent::new(view); + let _ = content.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(rx.try_recv().is_err(), "deferred update should wait until close"); + + let mut overlay = SettingsOverlayView::new(SettingsSection::Model); + overlay.set_model_content(content); + + overlay.set_mode_section(SettingsSection::Theme); + overlay.notify_close(); + + let event = rx + .try_recv() + .expect("closing overlay from non-model section should flush deferred model updates"); + + assert!(matches!( + event, + crate::app_event::AppEvent::UpdateServiceTierSelection { + service_tier: Some(code_core::config_types::ServiceTier::Fast), + } + )); } }