Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 144 additions & 6 deletions code-rs/tui/src/bottom_pane/model_selection_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ pub(crate) struct ModelSelectionView {
current_effort: ReasoningEffort,
current_service_tier: Option<ServiceTier>,
current_context_mode: Option<ContextMode>,
baseline_service_tier: Option<ServiceTier>,
baseline_context_mode: Option<ContextMode>,
defer_session_mode_toggles_until_close: bool,
staged_service_tier: Option<Option<ServiceTier>>,
staged_context_mode: Option<Option<ContextMode>>,
use_chat_model: bool,
app_event_tx: AppEventSender,
is_complete: bool,
Expand Down Expand Up @@ -191,13 +196,47 @@ 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,
target,
}
}

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<ModelPreset>) {
let include_fast_mode = self.target.supports_fast_mode();
let include_context_mode = self.target.supports_fast_mode();
Expand Down Expand Up @@ -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 => {
Expand All @@ -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 => {
Expand Down Expand Up @@ -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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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")];
Expand Down
105 changes: 78 additions & 27 deletions code-rs/tui/src/chatwidget/settings_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Flush deferred toggles when switching away from Model section

Deferring session-mode toggles here causes user changes to be dropped if they leave the Model section before closing settings. In section mode, Tab/arrow navigation is handled by settings_handlers.rs (because ModelSelectionView::handle_key_event_direct returns false for those keys), so the active section can change without closing the overlay; later close_settings_overlay() only calls notify_close() for the current active section, which means ModelSettingsContent::on_close may never run and the staged fast/1M updates are lost when the overlay is dropped.

Useful? React with 👍 / 👎.

Self { view }
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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::<crate::app_event::AppEvent>();
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),
}
));
}
}