fix(tui): preserve provider-selected models

This commit is contained in:
Hunter Bown
2026-05-14 15:35:15 -05:00
parent 63ab0a46a0
commit f8a4dee173
5 changed files with 206 additions and 47 deletions
+10 -1
View File
@@ -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
View File
@@ -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
+69 -42
View File
@@ -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();
+38 -3
View File
@@ -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;
+79
View File
@@ -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