fix(tui): preserve provider-selected models
This commit is contained in:
+10
-1
@@ -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
|
||||
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 <id>` 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<String>) -> 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<String>) -> 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();
|
||||
|
||||
@@ -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<String> {
|
||||
(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;
|
||||
|
||||
@@ -69,6 +69,49 @@ impl Drop for ConfigPathEnvGuard {
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsHomeGuard {
|
||||
_tmp: TempDir,
|
||||
previous_home: Option<OsString>,
|
||||
previous_userprofile: Option<OsString>,
|
||||
_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
|
||||
|
||||
Reference in New Issue
Block a user