diff --git a/CHANGELOG.md b/CHANGELOG.md index 5690d72e..1227038e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,11 +30,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Wrapped OSC 8 links keep their full target.** Long clickable URLs now reopen the original full link target on each wrapped visual chunk instead of exposing truncated hyperlink targets (#1577). +- **Provider-selected models survive startup and picker reselects.** The + `/model` picker now uses live provider model catalogs when available, saved + default providers sync into the runtime config before the first request, and + reselecting the active provider from the picker keeps the current model + instead of falling back to the provider default (#1632). ### Thanks Thanks to **DC ([@duanchao-lab](https://github.com/duanchao-lab))** for the -terminal cleanup-guard idea harvested from #1630. +terminal cleanup-guard idea harvested from #1630, and **imkingjh999 +([@imkingjh999](https://github.com/imkingjh999))** for the provider/model +switching fixes harvested from #1642. Thanks to **Photo +([@eng2007](https://github.com/eng2007))** for the provider-aware `/model` +picker catalog work harvested from #1201. ## [0.8.37] - 2026-05-14 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 5690d72e..1227038e 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -30,11 +30,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Wrapped OSC 8 links keep their full target.** Long clickable URLs now reopen the original full link target on each wrapped visual chunk instead of exposing truncated hyperlink targets (#1577). +- **Provider-selected models survive startup and picker reselects.** The + `/model` picker now uses live provider model catalogs when available, saved + default providers sync into the runtime config before the first request, and + reselecting the active provider from the picker keeps the current model + instead of falling back to the provider default (#1632). ### Thanks Thanks to **DC ([@duanchao-lab](https://github.com/duanchao-lab))** for the -terminal cleanup-guard idea harvested from #1630. +terminal cleanup-guard idea harvested from #1630, and **imkingjh999 +([@imkingjh999](https://github.com/imkingjh999))** for the provider/model +switching fixes harvested from #1642. Thanks to **Photo +([@eng2007](https://github.com/eng2007))** for the provider-aware `/model` +picker catalog work harvested from #1201. ## [0.8.37] - 2026-05-14 diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 88ce4949..ed6482a6 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -25,18 +25,40 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Widget}, }; +use std::collections::HashSet; +use crate::config::{ + ApiProvider, model_completion_names_for_provider, provider_passes_model_through, +}; use crate::palette; use crate::tui::app::{App, ReasoningEffort}; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; -/// Models the picker exposes by default. Kept short on purpose — power -/// users can still type `/model ` for anything else. -const PICKER_MODELS: &[(&str, &str)] = &[ - ("auto", "select per turn"), - ("deepseek-v4-pro", "flagship"), - ("deepseek-v4-flash", "fast / cheap"), -]; +fn picker_models_for_provider(provider: ApiProvider) -> Vec<(String, String)> { + let mut rows = vec![("auto".to_string(), "select per turn".to_string())]; + if provider_passes_model_through(provider) { + return rows; + } + rows.extend( + model_completion_names_for_provider(provider) + .into_iter() + .map(|id| (id.to_string(), String::new())), + ); + rows +} + +fn picker_rows_from_model_ids(model_ids: Vec) -> Vec<(String, String)> { + let mut rows = vec![("auto".to_string(), "select per turn".to_string())]; + let mut seen = HashSet::from(["auto".to_string()]); + for model_id in model_ids { + let id = model_id.trim(); + if id.is_empty() || !seen.insert(id.to_string()) { + continue; + } + rows.push((id.to_string(), String::new())); + } + rows +} /// Thinking-effort rows shown in the picker, in the order DeepSeek /// behaviorally distinguishes them. @@ -56,6 +78,7 @@ enum Pane { pub struct ModelPickerView { initial_model: String, initial_effort: ReasoningEffort, + model_rows: Vec<(String, String)>, /// Working selection (separate from the initial values so we can offer a /// clean Esc-to-cancel without mutating App state). selected_model_idx: usize, @@ -64,30 +87,29 @@ pub struct ModelPickerView { /// True when the active model is one we don't list — we still show it /// so the picker doesn't quietly forget the user's chosen IDs. show_custom_model_row: bool, - /// When true, hide DeepSeek-specific model rows (pass-through providers - /// like openai don't support them). - hide_deepseek_models: bool, } impl ModelPickerView { #[must_use] pub fn new(app: &App) -> Self { - let hide_deepseek_models = crate::config::provider_passes_model_through(app.api_provider); + Self::new_with_rows(app, picker_models_for_provider(app.api_provider)) + } + + #[must_use] + pub fn new_with_models(app: &App, model_ids: Vec) -> Self { + Self::new_with_rows(app, picker_rows_from_model_ids(model_ids)) + } + + fn new_with_rows(app: &App, model_rows: Vec<(String, String)>) -> Self { let initial_model = if app.auto_model { "auto".to_string() } else { app.model.clone() }; - // On pass-through providers, only show "auto" and the custom row. - let visible_models: Vec<&str> = if hide_deepseek_models { - vec!["auto"] - } else { - PICKER_MODELS.iter().map(|(id, _)| *id).collect() - }; - let mut selected_model_idx = visible_models.iter().position(|id| *id == initial_model); + let mut selected_model_idx = model_rows.iter().position(|(id, _)| *id == initial_model); let show_custom_model_row = selected_model_idx.is_none(); if show_custom_model_row { - selected_model_idx = Some(visible_models.len()); + selected_model_idx = Some(model_rows.len()); } let selected_model_idx = selected_model_idx.unwrap_or(0); @@ -105,35 +127,26 @@ impl ModelPickerView { Self { initial_model, initial_effort, + model_rows, selected_model_idx, selected_effort_idx, focus: Pane::Model, show_custom_model_row, - hide_deepseek_models, - } - } - - fn visible_model_ids(&self) -> Vec<&'static str> { - if self.hide_deepseek_models { - vec!["auto"] - } else { - PICKER_MODELS.iter().map(|(id, _)| *id).collect() } } fn model_row_count(&self) -> usize { - self.visible_model_ids().len() + if self.show_custom_model_row { 1 } else { 0 } + self.model_rows.len() + if self.show_custom_model_row { 1 } else { 0 } } /// Resolve the currently highlighted model row to a model id. If the /// custom row is selected we return the original model from the App so /// "Apply" doesn't blow away an unrecognised id. fn resolved_model(&self) -> String { - let visible = self.visible_model_ids(); - if self.show_custom_model_row && self.selected_model_idx == visible.len() { + if self.show_custom_model_row && self.selected_model_idx == self.model_rows.len() { self.initial_model.clone() - } else if self.selected_model_idx < visible.len() { - visible[self.selected_model_idx].to_string() + } else if let Some((model, _)) = self.model_rows.get(self.selected_model_idx) { + model.clone() } else { self.initial_model.clone() } @@ -324,14 +337,7 @@ impl ModalView for ModelPickerView { .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) .split(inner); - let mut model_rows: Vec<(String, String)> = if self.hide_deepseek_models { - vec![("auto".to_string(), "select per turn".to_string())] - } else { - PICKER_MODELS - .iter() - .map(|(id, hint)| ((*id).to_string(), (*hint).to_string())) - .collect() - }; + let mut model_rows = self.model_rows.clone(); if self.show_custom_model_row { model_rows.push((self.initial_model.clone(), "current (custom)".to_string())); } @@ -472,7 +478,8 @@ mod tests { #[test] fn picker_exposes_auto_and_distinct_thinking_tiers() { - let model_labels: Vec<_> = PICKER_MODELS.iter().map(|(id, _)| *id).collect(); + let model_rows = picker_models_for_provider(crate::config::ApiProvider::Deepseek); + let model_labels: Vec<_> = model_rows.iter().map(|(id, _)| id.as_str()).collect(); assert_eq!( model_labels, vec!["auto", "deepseek-v4-pro", "deepseek-v4-flash"] @@ -495,6 +502,26 @@ mod tests { assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX"); } + #[test] + fn picker_uses_live_provider_model_ids_when_supplied() { + let (mut app, _lock) = create_test_app(); + app.api_provider = crate::config::ApiProvider::Openrouter; + app.model = "meta-llama/llama-3.1-405b-instruct".to_string(); + app.auto_model = false; + + let view = ModelPickerView::new_with_models( + &app, + vec![ + "deepseek/deepseek-chat-v3.1".to_string(), + "meta-llama/llama-3.1-405b-instruct".to_string(), + "qwen/qwen3-coder".to_string(), + ], + ); + + assert!(!view.show_custom_model_row); + assert_eq!(view.resolved_model(), "meta-llama/llama-3.1-405b-instruct"); + } + #[test] fn arrow_keys_move_within_focused_pane() { let (app, _lock) = create_test_app(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 87757c82..5312c444 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -345,6 +345,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { let mut config = config.clone(); let config = &mut config; let mut app = App::new(options.clone(), config); + sync_config_provider_from_app(config, &app); // Load existing session if resuming. if let Some(ref session_id) = options.resume_session_id @@ -4264,6 +4265,14 @@ async fn switch_provider( } } +fn sync_config_provider_from_app(config: &mut Config, app: &App) { + config.provider = Some(app.api_provider.as_str().to_string()); +} + +fn provider_picker_model_override(app: &App, provider: ApiProvider) -> Option { + (app.api_provider == provider).then(|| app.model.clone()) +} + fn open_text_pager(app: &mut App, title: String, content: String) { let width = app .viewport @@ -4502,8 +4511,33 @@ async fn apply_command_result( } AppAction::OpenModelPicker => { if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) { - app.view_stack - .push(crate::tui::model_picker::ModelPickerView::new(app)); + app.status_message = + Some(format!("Fetching {} models...", app.api_provider.as_str())); + let picker = match fetch_available_models(config).await { + Ok(models) if !models.is_empty() => { + app.status_message = Some(format!("Found {} model(s)", models.len())); + crate::tui::model_picker::ModelPickerView::new_with_models(app, models) + } + Ok(_) => { + app.status_message = Some(format!( + "{} returned no models; showing defaults", + app.api_provider.as_str() + )); + crate::tui::model_picker::ModelPickerView::new(app) + } + Err(error) => { + app.add_message(HistoryCell::System { + content: format!( + "Failed to fetch {} models: {error}. Showing built-in defaults.", + app.api_provider.as_str() + ), + }); + app.status_message = + Some("Model fetch failed; showing defaults".to_string()); + crate::tui::model_picker::ModelPickerView::new(app) + } + }; + app.view_stack.push(picker); } } AppAction::OpenProviderPicker => { @@ -5907,7 +5941,8 @@ async fn handle_view_events( .await; } ViewEvent::ProviderPickerApplied { provider } => { - switch_provider(app, engine_handle, config, provider, None).await; + let model_override = provider_picker_model_override(app, provider); + switch_provider(app, engine_handle, config, provider, model_override).await; } ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => { apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 5978750e..d1128f37 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -69,6 +69,49 @@ impl Drop for ConfigPathEnvGuard { } } +struct SettingsHomeGuard { + _tmp: TempDir, + previous_home: Option, + previous_userprofile: Option, + _lock: MutexGuard<'static, ()>, +} + +impl SettingsHomeGuard { + fn new() -> Self { + let lock = crate::test_support::lock_test_env(); + let tmp = TempDir::new().expect("settings tempdir"); + let previous_home = std::env::var_os("HOME"); + let previous_userprofile = std::env::var_os("USERPROFILE"); + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + std::env::set_var("HOME", tmp.path()); + std::env::set_var("USERPROFILE", tmp.path()); + } + Self { + _tmp: tmp, + previous_home, + previous_userprofile, + _lock: lock, + } + } +} + +impl Drop for SettingsHomeGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + match self.previous_home.take() { + Some(previous) => std::env::set_var("HOME", previous), + None => std::env::remove_var("HOME"), + } + match self.previous_userprofile.take() { + Some(previous) => std::env::set_var("USERPROFILE", previous), + None => std::env::remove_var("USERPROFILE"), + } + } + } +} + #[test] fn resume_hint_uses_canonical_resume_command() { assert_eq!( @@ -1644,6 +1687,42 @@ async fn model_change_update_syncs_engine_model_before_compaction() { } } +#[test] +fn saved_default_provider_syncs_back_to_runtime_config() { + let _home = SettingsHomeGuard::new(); + let settings = crate::settings::Settings { + default_provider: Some("ollama".to_string()), + ..Default::default() + }; + settings.save().expect("save settings"); + + let mut config = Config::default(); + assert_eq!(config.api_provider(), ApiProvider::Deepseek); + + let app = App::new(create_test_options(), &config); + assert_eq!(app.api_provider, ApiProvider::Ollama); + + sync_config_provider_from_app(&mut config, &app); + + assert_eq!(config.api_provider(), ApiProvider::Ollama); +} + +#[test] +fn provider_picker_reselecting_active_provider_preserves_current_model() { + let mut app = create_test_app(); + app.api_provider = ApiProvider::Ollama; + app.model = "deepseek-coder-v2:16b".to_string(); + + assert_eq!( + provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(), + Some("deepseek-coder-v2:16b") + ); + assert_eq!( + provider_picker_model_override(&app, ApiProvider::Deepseek), + None + ); +} + #[tokio::test] async fn provider_switch_clears_turn_cache_history() { // `switch_provider` persists the new provider to `Settings`, which