use super::super::*;
use super::{MissionControlApp, ModelSelectionWorkerOutcome};
fn run_model_selection_job(
paths: crate::config::McPaths,
auth_state: crate::config::AuthState,
model_id: String,
cancel: &AtomicBool,
) -> Option<Result<TuiModelSelectionResult, String>> {
if cancel.load(Ordering::SeqCst) {
return None;
}
let (provider, model, notice, service_tier) =
match crate::model_catalog::resolve_model_with_fast_service_tier(
&paths,
&auth_state,
&model_id,
) {
Ok(result) => result,
Err(error) => return Some(Err(error.to_string())),
};
if cancel.load(Ordering::SeqCst) {
return None;
}
let config = match crate::config::load_effective_provider_selection(&paths, &provider, &model) {
Ok(config) => config,
Err(error) => return Some(Err(error.to_string())),
};
if cancel.load(Ordering::SeqCst) {
return None;
}
if let Err(error) = crate::model_catalog::persist_selected_model(&paths, &provider, &model) {
return Some(Err(error.to_string()));
}
let thinking_levels = cached_thinking_levels_for_model(
&config.paths,
&provider,
&model,
crate::thinking::capability_scope_for_provider(&config.custom_providers, &provider),
);
Some(Ok(TuiModelSelectionResult {
provider,
model,
notice,
service_tier,
config,
thinking_levels,
}))
}
impl MissionControlApp {
pub(crate) fn select_model_from_text(
&mut self,
model_id: &str,
ui_state: &mut state::MissionControlState,
) {
self.select_model(model_id, ui_state);
}
pub(crate) fn select_model(
&mut self,
model_id: &str,
ui_state: &mut state::MissionControlState,
) {
if self.settings_persistence.is_pending() {
ui_state.status = "settings persistence pending; action blocked".to_string();
return;
}
if self.active_run || self.worker.is_some() {
Self::apply_ui_error(ui_state, "cannot change model while a prompt is running");
return;
}
if self.pending_model_selection.is_some() {
ui_state.status = "model selection already in progress".to_string();
return;
}
self.next_model_selection_request_id =
self.next_model_selection_request_id.saturating_add(1);
let request_id = self.next_model_selection_request_id;
let session_id = self.state.active_session_id().map(ToOwned::to_owned);
let generation = self.session_generation;
let provider = self.state.auth_state.provider().to_string();
let model = self.state.model.clone();
self.pending_model_selection = Some(super::PendingModelSelection {
request_id,
session_id: session_id.clone(),
generation,
provider,
model,
});
ui_state.status = "switching model…".to_string();
let paths = self.config.paths.clone();
let auth_state = self.state.auth_state.clone();
let model_id = model_id.to_string();
let session = self.state.current_session.clone();
let cwd = self.state.cwd.clone();
let sender = self.events.clone();
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel);
let worker_session_id = session_id.clone();
let handle = thread::Builder::new()
.name("magi-model-selection".to_string())
.spawn(move || {
let Some(result) =
run_model_selection_job(paths, auth_state, model_id, &worker_cancel)
else {
return ModelSelectionWorkerOutcome { result: None };
};
if let Err(message) = &result {
let _ = record_session_event(
session.as_ref(),
&cwd,
SessionEventKind::Diagnostic,
serde_json::json!({"level": "error", "message": message}),
);
}
let _ = send_tui_event(
&sender,
TuiEvent::ModelSelectionFinished {
request_id,
session_id: worker_session_id,
generation,
result: Box::new(result.clone()),
},
);
ModelSelectionWorkerOutcome {
result: Some(result),
}
});
match handle {
Ok(handle) => self
.model_selection_workers
.push(super::ModelSelectionWorker {
request_id,
cancel,
handle,
reconciled: false,
}),
Err(error) => {
self.pending_model_selection = None;
ui_state.status =
format!("model selection failed: could not start worker: {error}");
}
}
}
pub(in crate::tui) fn handle_model_selection_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
) -> bool {
let mut changed = false;
for finished in &drain_result.model_selection_finished {
let request_id = finished.request_id;
let session_id = &finished.session_id;
let generation = finished.generation;
let result = &finished.result;
let Some(pending) = self.pending_model_selection.clone() else {
continue;
};
if pending.request_id != request_id
|| pending.session_id != *session_id
|| pending.generation != generation
{
continue;
}
if let Some(worker) = self
.model_selection_workers
.iter_mut()
.find(|worker| worker.request_id == request_id)
{
worker.reconciled = true;
}
self.pending_model_selection = None;
let state_changed = self.active_run
|| self.worker.is_some()
|| self.session_generation != generation
|| self.state.active_session_id() != session_id.as_deref()
|| self.state.auth_state.provider() != pending.provider
|| self.state.model != pending.model
|| ui_state.provider != pending.provider
|| ui_state.model != pending.model;
if state_changed && result.is_err() {
ui_state.status = "model selection failed: state changed".to_string();
changed = true;
continue;
}
match result {
Ok(result) => {
let TuiModelSelectionResult {
provider,
model,
notice,
service_tier: _service_tier,
config,
thinking_levels,
} = result.clone();
self.state.model = model.clone();
self.state.auth_state = config.auth_state();
self.state.config = Some(config.clone());
self.config.provider = config.provider.clone();
self.config.model = config.model.clone();
self.config.auth = config.auth.clone();
self.config.custom_providers = config.custom_providers.clone();
self.config.thinking_level = config.thinking_level;
self.summarizer.refresh_config(&self.config, &self.settings);
ui_state.provider = provider.clone();
ui_state.provider_ready = self.state.auth_state.is_ready();
ui_state.model = model.clone();
ui_state.refresh_thinking_levels(config.thinking_level, thinking_levels);
ui_state.reset_context_for_model_switch();
self.refresh_fast_mode_state(ui_state);
ui_state.close_model_picker();
ui_state.status = format!("model switched to {provider}/{model}");
if let Some(notice) = notice {
ui_state.status.push_str(&format!(" ({notice})"));
}
}
Err(error) => Self::apply_ui_error(ui_state, error),
}
changed = true;
}
changed
}
pub(in crate::tui) fn reap_model_selection_workers(
&mut self,
ui_state: &mut state::MissionControlState,
) -> bool {
let mut changed = false;
let mut active_workers = Vec::with_capacity(self.model_selection_workers.len());
for worker in std::mem::take(&mut self.model_selection_workers) {
if !worker.handle.is_finished() {
active_workers.push(worker);
continue;
}
match worker.handle.join() {
Ok(outcome) => match outcome.result {
Some(result) if !worker.reconciled => {
let (session_id, generation) = self
.pending_model_selection
.as_ref()
.filter(|pending| pending.request_id == worker.request_id)
.map(|pending| (pending.session_id.clone(), pending.generation))
.unwrap_or((None, self.session_generation));
let mut drain = DrainResult::default();
drain.model_selection_finished.push(ModelSelectionFinished {
request_id: worker.request_id,
session_id,
generation,
result,
});
changed |= self.handle_model_selection_drain(ui_state, &drain);
}
None => {
if self
.pending_model_selection
.as_ref()
.is_some_and(|pending| pending.request_id == worker.request_id)
{
self.pending_model_selection = None;
ui_state.status = "model selection cancelled".to_string();
changed = true;
}
}
Some(_) => {}
},
Err(_) => {
if self
.pending_model_selection
.as_ref()
.is_some_and(|pending| pending.request_id == worker.request_id)
{
self.pending_model_selection = None;
ui_state.status = "model selection worker panicked".to_string();
changed = true;
}
}
}
}
self.model_selection_workers = active_workers;
changed
}
pub(super) fn cancel_model_selection_workers(&self) {
for worker in &self.model_selection_workers {
worker.cancel.store(true, Ordering::SeqCst);
}
}
pub(in crate::tui) fn join_model_selection_workers_for_cleanup(&mut self) -> Vec<String> {
let mut errors = Vec::new();
for worker in std::mem::take(&mut self.model_selection_workers) {
match worker.handle.join() {
Ok(outcome) => match outcome.result {
Some(Err(error)) => errors.push(format!(
"model selection worker {} failed: {}",
worker.request_id, error
)),
Some(Ok(_)) => {}
None => {}
},
Err(_) => errors.push(format!(
"model selection worker {} panicked",
worker.request_id
)),
}
}
self.pending_model_selection = None;
errors
}
pub(crate) fn open_model_picker(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
if self.settings_persistence.catalog_pending() || self.model_catalog_loading {
ui_state.status = "model catalog refresh already in progress".into();
return;
}
if self.pending_model_selection.is_some() {
ui_state.status = "model selection already in progress".to_string();
return;
}
if let Some(catalog) = &self.model_catalog_cache {
let disabled = crate::config::disabled_model_ids_from_settings(&self.settings);
let notice = if catalog.entries.is_empty() {
Some("No cached models. Use /settings → Models → Refresh catalog.".to_string())
} else {
catalog.notice.clone()
};
let visible_rows = model_picker_visible_rows(terminal_area, notice.is_some());
ui_state.open_model_picker(catalog.entries.clone(), disabled, notice, visible_rows);
ui_state.status = "model catalog loaded".to_string();
return;
}
ui_state.status = "loading model catalogs…".to_string();
self.model_catalog_loading = true;
self.pending_model_catalog_consumer = Some(super::PendingModelCatalogConsumer::ModelPicker);
self.next_model_catalog_request_id += 1;
let request_id = self.next_model_catalog_request_id;
ui_state.pending_model_catalog_request_id = Some(request_id);
let paths = self.config.paths.clone();
let sender = self.events.clone();
thread::spawn(move || {
let agg = crate::model_catalog::load_aggregated_catalog(
&paths,
crate::model_catalog::CachePreference::CacheOnly,
);
let notice = if agg.entries.is_empty() {
Some("No cached models. Use /settings → Models → Refresh catalog.".to_string())
} else if agg.notices.is_empty() {
None
} else {
Some(agg.notices.join("; "))
};
let visible_rows = model_picker_visible_rows(terminal_area, notice.is_some());
let result = Ok(crate::model_catalog::CatalogForUi {
entries: agg.entries,
stale: agg.stale,
notice,
});
let _ = send_critical(
&sender,
TuiEvent::ModelCatalog {
request_id,
result,
visible_rows,
},
);
});
}
}
#[cfg(test)]
mod tests {
use super::super::tests::{test_app, test_area};
use super::*;
#[test]
fn model_picker_and_selection_never_request_provider_with_missing_empty_or_stale_cache() {
for cached_model in [None, Some(""), Some("cached-model")] {
let temp = tempfile::TempDir::new().unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let (sender, receiver) = crossbeam_channel::bounded(8);
let mut app = test_app(&temp, sender);
let paths = app.config.paths.clone();
let settings: crate::config::Settings = serde_json::from_value(serde_json::json!({
"providers": {
"custom": {
"local": {
"label": "Local",
"base_url": format!("http://{}/v1", listener.local_addr().unwrap())
}
}
}
}))
.unwrap();
crate::config::write_settings(&paths, &settings).unwrap();
if let Some(model) = cached_model {
let entries = if model.is_empty() {
Vec::new()
} else {
vec![crate::model_catalog::ModelCatalogEntry::new("local", model)]
};
crate::model_catalog::write_catalog_cache_for_configured_provider(
&paths, "local", &entries,
)
.unwrap();
let cache_path = crate::model_catalog::catalog_cache_path(&paths, "local").unwrap();
let mut cache: serde_json::Value =
serde_json::from_slice(&std::fs::read(&cache_path).unwrap()).unwrap();
cache["expires_at"] = serde_json::json!("2000-01-01T00:00:00Z");
std::fs::write(cache_path, serde_json::to_vec(&cache).unwrap()).unwrap();
}
let mut ui = state::MissionControlState::default();
app.open_model_picker(&mut ui, test_area());
let TuiEvent::ModelCatalog { result, .. } =
receiver.recv_timeout(Duration::from_secs(5)).unwrap()
else {
panic!("expected cached catalog completion");
};
let catalog = result.unwrap();
assert_eq!(
catalog.entries.len(),
usize::from(cached_model == Some("cached-model"))
);
if catalog.entries.is_empty() {
assert!(
catalog
.notice
.as_deref()
.unwrap()
.contains("/settings → Models → Refresh catalog")
);
}
let result = run_model_selection_job(
paths.clone(),
app.state.auth_state.clone(),
"local/cached-model".into(),
&AtomicBool::new(false),
)
.unwrap();
if cached_model == Some("cached-model") {
let selected = result.unwrap();
assert_eq!(selected.model, "cached-model");
assert_eq!(
crate::config::read_settings(&paths)
.unwrap()
.selected_model
.model
.as_deref(),
Some("cached-model")
);
} else {
assert!(
result
.unwrap_err()
.contains("/settings → Models → Refresh catalog")
);
assert!(
crate::config::read_settings(&paths)
.unwrap()
.selected_model
.model
.is_none()
);
}
assert_eq!(
listener.accept().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
}
}
}