use super::super::*;
use super::MissionControlApp;
use crate::config::{Settings, SettingsListKind, SettingsScope};
#[derive(Debug, Clone)]
pub(super) enum SettingsChange {
EditorLoad(SettingsScope),
EditorSave {
scope: SettingsScope,
changes: Vec<(&'static str, serde_json::Value)>,
},
Toggle {
kind: SettingsListKind,
scope: SettingsScope,
name: String,
enabled: bool,
},
Mcp {
name: String,
enabled: bool,
},
Thinking(crate::thinking::ThinkingLevel),
PrimaryAgent(Option<String>),
}
impl SettingsChange {
fn persist(&self, paths: &crate::config::McPaths) -> anyhow::Result<Settings> {
match self {
Self::EditorLoad(scope) => return crate::config::load_settings_editor(paths, *scope),
Self::EditorSave { scope, changes } => {
return crate::config::save_settings_editor(paths, *scope, changes);
}
Self::Toggle {
kind,
scope,
name,
enabled,
} => {
match kind {
SettingsListKind::Skills => {
crate::config::set_skill_disabled_for_scope(paths, *scope, name, !enabled)?
}
SettingsListKind::Tools => {
crate::config::set_tool_disabled(paths, *scope, name, !enabled)?
}
SettingsListKind::Subagents => {
crate::config::set_subagent_profile_disabled(paths, *scope, name, !enabled)?
}
SettingsListKind::Models => {
crate::config::set_model_disabled_for_scope(paths, *scope, name, !enabled)?
}
};
}
Self::Mcp { name, enabled } => {
crate::config::set_mcp_server_enabled(paths, name, *enabled)?
}
Self::Thinking(level) => {
crate::config::set_thinking_level(paths, *level)?;
}
Self::PrimaryAgent(id) => {
crate::config::set_selected_primary_agent(paths, id.as_deref())?;
}
}
crate::config::read_settings(paths)
}
fn set_row(&self, ui: &mut state::MissionControlState, saved: bool) {
match self {
Self::Toggle {
kind,
scope,
name,
enabled,
} => {
let value = if saved { *enabled } else { !enabled };
match kind {
SettingsListKind::Skills if ui.skills_modal_scope() == Some(*scope) => {
ui.set_skill_row_enabled(name, value)
}
SettingsListKind::Tools if ui.tools_modal_scope() == Some(*scope) => {
ui.set_tool_row_enabled(name, value)
}
SettingsListKind::Subagents if ui.subagents_modal_scope() == Some(*scope) => {
ui.set_subagent_profile_row_enabled(name, value)
}
_ => {}
}
}
Self::Mcp { name, enabled } => {
ui.set_mcp_server_row_enabled(name, if saved { *enabled } else { !enabled })
}
_ => {}
}
}
}
struct SettingsWorker {
request_id: u64,
change: SettingsChange,
handle: super::completion_worker::CompletionWorker<Settings>,
}
#[derive(Default)]
pub(super) struct SettingsPersistenceRuntime {
next_request_id: u64,
worker: Option<SettingsWorker>,
retained_failure: Option<String>,
catalog: Option<super::completion_worker::CompletionWorker<crate::model_catalog::CatalogForUi>>,
}
impl SettingsPersistenceRuntime {
pub(super) fn is_pending(&self) -> bool {
self.worker.is_some()
}
pub(super) fn catalog_pending(&self) -> bool {
self.catalog.is_some()
}
pub(super) fn cleanup(&mut self) -> Vec<String> {
let mut errors = self
.retained_failure
.clone()
.into_iter()
.collect::<Vec<_>>();
if let Some(worker) = self.catalog.take() {
let deadline = Instant::now() + WORKER_EXIT_JOIN_TIMEOUT;
while !worker.is_finished() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(5));
}
if worker.is_finished() {
if let Err(error) = worker.join() {
errors.push(error);
}
} else {
errors.push("settings catalog refresh did not finish before shutdown".into());
}
}
if let Some(worker) = self.worker.take() {
let deadline = Instant::now() + WORKER_EXIT_JOIN_TIMEOUT;
while !worker.handle.is_finished() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(5));
}
if !worker.handle.is_finished() {
errors.push(format!(
"settings persistence request {} did not finish before shutdown",
worker.request_id
));
} else if let Err(error) = worker.handle.join() {
errors.push(error);
}
}
errors
}
}
impl MissionControlApp {
pub(super) fn load_settings_editor(
&mut self,
scope: SettingsScope,
ui: &mut state::MissionControlState,
) {
if self.settings_persistence.is_pending()
|| self.pending_model_selection.is_some()
|| self.model_catalog_loading
{
ui.status = "Wait for the current settings operation to finish.".into();
return;
}
ui.hide_autocomplete();
let tab = ui
.modals
.settings_editor
.as_ref()
.map_or(0, |editor| editor.tab);
ui.modals.settings_editor =
Some(crate::tui::settings_editor::SettingsEditor::loading(scope));
ui.modals.settings_editor.as_mut().unwrap().tab = tab;
if let Some(catalog) = &self.model_catalog_cache {
ui.modals
.settings_editor
.as_mut()
.unwrap()
.set_catalog(catalog);
}
self.start_settings_persistence(SettingsChange::EditorLoad(scope), ui);
self.update_settings_editor_pending(ui);
if self.model_catalog_cache.is_none() && !self.settings_persistence.catalog_pending() {
self.start_settings_catalog(crate::model_catalog::CachePreference::CacheOnly, ui);
}
ui.modals.settings_editor.as_mut().unwrap().catalog_loading =
self.settings_persistence.catalog.is_some() || self.model_catalog_loading;
}
fn update_settings_editor_pending(&self, ui: &mut state::MissionControlState) {
if let Some(editor) = &mut ui.modals.settings_editor {
editor.busy = self.settings_persistence.is_pending();
editor.notice = ui.status.clone();
}
}
pub(super) fn handle_settings_editor_key(
&mut self,
key: crossterm::event::KeyEvent,
ui: &mut state::MissionControlState,
) {
use crate::tui::settings_editor::EditorRequest;
let Some(editor) = &mut ui.modals.settings_editor else {
return;
};
match editor.key(key) {
EditorRequest::None => {}
EditorRequest::Close => ui.modals.settings_editor = None,
EditorRequest::Load(scope) => self.load_settings_editor(scope, ui),
EditorRequest::RefreshCatalog => self.refresh_settings_catalog(ui),
EditorRequest::Save => {
let change = SettingsChange::EditorSave {
scope: editor.scope,
changes: editor.changes(),
};
self.start_settings_persistence(change, ui);
self.update_settings_editor_pending(ui);
}
}
}
pub(super) fn refresh_settings_catalog(&mut self, ui: &mut state::MissionControlState) {
let Some(editor) = &mut ui.modals.settings_editor else {
return;
};
if self.settings_persistence.catalog.is_some()
|| self.model_catalog_loading
|| self.pending_model_selection.is_some()
{
editor.catalog_notice = "Catalog operation already in progress.".into();
return;
}
if self.active_run || self.worker.is_some() {
editor.catalog_notice = "Wait for the active run to finish before refreshing.".into();
return;
}
self.start_settings_catalog(crate::model_catalog::CachePreference::RefreshAllowStale, ui);
}
fn start_settings_catalog(
&mut self,
preference: crate::model_catalog::CachePreference,
ui: &mut state::MissionControlState,
) {
let Some(editor) = &mut ui.modals.settings_editor else {
return;
};
let paths = self.config.paths.clone();
match super::completion_worker::CompletionWorker::spawn(
"magi-settings-catalog",
self.events.clone(),
move || {
let catalog = crate::model_catalog::load_aggregated_catalog(&paths, preference);
Ok(crate::model_catalog::CatalogForUi {
entries: catalog.entries,
stale: catalog.stale,
notice: (!catalog.notices.is_empty()).then(|| catalog.notices.join("; ")),
})
},
) {
Ok(worker) => {
self.settings_persistence.catalog = Some(worker);
editor.catalog_loading = true;
editor.catalog_notice = match preference {
crate::model_catalog::CachePreference::CacheOnly => "Loading cached catalog…",
_ => "Refreshing catalog…",
}
.into();
}
Err(error) => editor.catalog_notice = format!("Catalog refresh failed: {error}"),
}
}
fn reap_settings_catalog(&mut self, ui: &mut state::MissionControlState) -> bool {
let Some(worker) = &mut self.settings_persistence.catalog else {
return false;
};
let result = worker.take_result();
let reaped = worker.ready_to_reap();
if reaped {
let _ = self.settings_persistence.catalog.take().unwrap().join();
}
if let Some(editor) = &mut ui.modals.settings_editor {
editor.catalog_loading = !reaped;
}
let Some(result) = result else {
return reaped;
};
match result {
Ok(catalog) => {
if let Some(editor) = &mut ui.modals.settings_editor {
editor.set_catalog(&catalog);
}
self.model_catalog_cache = Some(catalog);
}
Err(error) => {
if let Some(editor) = &mut ui.modals.settings_editor {
editor.catalog_notice = format!("Catalog refresh failed: {error}");
}
}
}
true
}
pub(super) fn start_settings_persistence(
&mut self,
change: SettingsChange,
ui: &mut state::MissionControlState,
) {
change.set_row(ui, false);
if matches!(change, SettingsChange::Thinking(_)) {
ui.refresh_thinking_levels(self.config.thinking_level, ui.thinking_levels.clone());
}
if matches!(change, SettingsChange::PrimaryAgent(_)) {
ui.set_selected_primary_agent_id(self.settings.selected_primary_agent.as_deref());
}
if self.pending_model_selection.is_some() {
ui.status = "model selection pending; settings action blocked".into();
return;
}
if self.settings_persistence.is_pending() {
ui.status = "settings persistence already in progress".into();
return;
}
if self.settings_persistence.retained_failure.is_some() {
ui.status =
"settings persistence failed; restart Mission Control before retrying".into();
return;
}
let paths = self.config.paths.clone();
let task = change.clone();
let disabled_tools = Arc::clone(&self.disabled_tools);
let disabled_subagents = Arc::clone(&self.disabled_subagent_profiles);
let handle = super::completion_worker::CompletionWorker::spawn(
"magi-settings-persistence",
self.events.clone(),
move || {
panic::catch_unwind(panic::AssertUnwindSafe(|| -> anyhow::Result<Settings> {
let settings = task.persist(&paths)?;
if matches!(
task,
SettingsChange::Toggle {
kind: SettingsListKind::Skills
| SettingsListKind::Tools
| SettingsListKind::Subagents,
..
}
) {
let mut tools = disabled_tools
.lock()
.map_err(|_| anyhow::anyhow!("disabled tools lock poisoned"))?;
let mut subagents = disabled_subagents.lock().map_err(|_| {
anyhow::anyhow!("disabled subagent profiles lock poisoned")
})?;
*tools = crate::config::disabled_tool_names_from_settings(&settings)
.into_iter()
.collect();
*subagents =
crate::config::disabled_subagent_profile_names_from_settings(&settings)
.into_iter()
.collect();
}
Ok(settings)
}))
.map_err(|_| "settings persistence worker panicked".to_string())
.and_then(|r| r.map_err(|e| e.to_string()))
},
);
match handle {
Ok(handle) => {
self.settings_persistence.next_request_id += 1;
ui.status = if matches!(change, SettingsChange::EditorLoad(_)) {
"Loading settings…".into()
} else {
"Saving settings…".into()
};
self.settings_persistence.worker = Some(SettingsWorker {
request_id: self.settings_persistence.next_request_id,
change,
handle,
});
}
Err(error) => ui.status = format!("failed to start settings persistence: {error}"),
}
}
pub(in crate::tui) fn reap_settings_persistence_worker(
&mut self,
ui: &mut state::MissionControlState,
) -> bool {
let catalog_changed = self.reap_settings_catalog(ui);
let Some(worker) = self.settings_persistence.worker.as_mut() else {
return catalog_changed;
};
let result = worker.handle.take_result();
let change = worker.change.clone();
let mut editor_unblocked = false;
if worker.handle.ready_to_reap()
&& let Some(worker) = self.settings_persistence.worker.take()
{
let _ = worker.handle.join();
if let Some(editor) = &mut ui.modals.settings_editor {
editor.busy = false;
editor_unblocked = true;
}
}
let Some(result) = result else {
return editor_unblocked || catalog_changed;
};
if matches!(
change,
SettingsChange::EditorLoad(_) | SettingsChange::EditorSave { .. }
) {
if let Some(editor) = &mut ui.modals.settings_editor {
match result {
Ok(settings) => {
if matches!(change, SettingsChange::EditorLoad(_)) {
editor.loaded(&settings);
} else {
editor.mark_saved();
}
}
Err(error) => editor.notice = format!("Settings operation failed: {error}"),
}
editor.busy = self.settings_persistence.is_pending();
ui.status = editor.notice.clone();
}
return true;
}
match result {
Ok(settings) => {
change.set_row(ui, true);
match &change {
SettingsChange::Toggle { kind, .. } => match kind {
SettingsListKind::Models => self.settings.models = settings.models,
_ => {
self.skills = filter_enabled_skills(
&self.discovered_skills,
&crate::config::disabled_skill_names_from_settings(&settings),
)
}
},
SettingsChange::Thinking(level) => {
self.config.thinking_level = *level;
self.summarizer.refresh_config(&self.config, &self.settings);
if let Some(config) = &mut self.state.config {
config.thinking_level = *level;
}
ui.refresh_thinking_levels(*level, ui.thinking_levels.clone());
}
SettingsChange::PrimaryAgent(_) => {
self.settings.selected_primary_agent = settings.selected_primary_agent;
ui.set_selected_primary_agent_id(
self.settings.selected_primary_agent.as_deref(),
);
}
_ => {}
}
ui.status = match change {
SettingsChange::EditorLoad(_) | SettingsChange::EditorSave { .. } => {
unreachable!("editor outcomes handled above")
}
SettingsChange::Mcp { .. } => {
"MCP server setting saved; takes effect next magi-code launch".into()
}
SettingsChange::Thinking(_) => {
format!("thinking level selected: {}", ui.thinking_label())
}
SettingsChange::PrimaryAgent(Some(id)) => {
format!("primary agent selected: {id}")
}
SettingsChange::PrimaryAgent(None) => "primary agent selection cleared".into(),
SettingsChange::Toggle {
kind,
scope,
name,
enabled,
} => format!(
"{} {} in {} settings: {name}",
match kind {
SettingsListKind::Skills => "skill",
SettingsListKind::Tools => "tool",
SettingsListKind::Subagents => "subagent",
SettingsListKind::Models => "model",
},
if enabled { "enabled" } else { "disabled" },
scope.label().to_ascii_lowercase(),
),
};
}
Err(error) => {
ui.status = format!(
"failed to save setting: {error}; restart Mission Control before retrying"
);
self.settings_persistence.retained_failure = Some(error);
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::settings_editor::SettingsEditor;
#[test]
fn settings_editor_loads_disk_catalog_without_network_even_when_stale_or_missing() {
for cache_age in [None, Some(0), Some(48)] {
let temp = tempfile::tempdir().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
drop(receiver);
let mut app = super::super::tests::test_app(&temp, sender);
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let custom = crate::config::CustomProviderConfig {
base_url: format!("http://{}/v1", listener.local_addr().unwrap()),
api_key_env_var: None,
label: "Local".into(),
models_dev_provider: None,
fast_mode: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: Default::default(),
extra_models: Vec::new(),
request_headers: Default::default(),
};
crate::config::write_settings(
&app.config.paths,
&Settings {
custom_providers: std::collections::BTreeMap::from([("local".into(), custom)]),
..Default::default()
},
)
.unwrap();
if let Some(hours) = cache_age {
for provider in ["local", "openai-codex", "anthropic"] {
crate::model_catalog::write_catalog_cache_for_configured_provider(
&app.config.paths,
provider,
&[crate::model_catalog::ModelCatalogEntry::new(
provider,
"disk-model",
)],
)
.unwrap();
if hours > 0 {
let path =
crate::model_catalog::catalog_cache_path(&app.config.paths, provider)
.unwrap();
let mut cache: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
cache["expires_at"] =
serde_json::json!(chrono::Utc::now() - chrono::Duration::hours(hours));
std::fs::write(path, serde_json::to_vec(&cache).unwrap()).unwrap();
}
}
}
assert!(app.model_catalog_cache.is_none());
let mut ui = state::MissionControlState::default();
app.load_settings_editor(SettingsScope::Global, &mut ui);
let deadline = Instant::now() + Duration::from_secs(3);
while app.settings_persistence.is_pending()
|| app.settings_persistence.catalog_pending()
{
app.reap_settings_persistence_worker(&mut ui);
assert!(
Instant::now() < deadline,
"cache-only settings load timed out"
);
thread::yield_now();
}
let editor = ui.modals.settings_editor.as_ref().unwrap();
assert!(!editor.catalog_loading);
if cache_age.is_some() {
for provider in ["local", "openai-codex", "anthropic"] {
assert!(
editor
.models
.iter()
.any(|(id, _)| id == &format!("{provider}/disk-model"))
);
}
assert_eq!(
app.model_catalog_cache.as_ref().unwrap().stale,
cache_age == Some(48)
);
if cache_age == Some(48) {
assert!(editor.catalog_notice.contains("stale"));
assert!(!editor.catalog_notice.contains("failed"));
}
} else {
assert!(editor.models.is_empty());
}
assert_eq!(
listener.accept().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
}
}
#[test]
fn catalog_failure_reconciles_without_wake_and_preserves_drafts_and_rows() {
let temp = tempfile::tempdir().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(1);
drop(receiver);
let mut app = super::super::tests::test_app(&temp, sender.clone());
let mut ui = state::MissionControlState::default();
let mut editor = SettingsEditor::loading(SettingsScope::Project);
editor.loaded(&Settings::default());
editor.fields[0].value = serde_json::json!(true);
editor.models = vec![("provider/model".into(), "Model".into())];
let changes = editor.changes();
ui.modals.settings_editor = Some(editor);
app.settings_persistence.catalog = Some(
super::super::completion_worker::CompletionWorker::spawn(
"catalog-test-failure",
sender,
|| Err("offline".into()),
)
.unwrap(),
);
app.refresh_settings_catalog(&mut ui);
assert!(
ui.modals
.settings_editor
.as_ref()
.unwrap()
.catalog_notice
.contains("already in progress")
);
let deadline = Instant::now() + Duration::from_secs(2);
while app.settings_persistence.catalog_pending() {
app.reap_settings_persistence_worker(&mut ui);
assert!(Instant::now() < deadline);
thread::yield_now();
}
let editor = ui.modals.settings_editor.as_ref().unwrap();
assert!(!editor.catalog_loading);
assert!(editor.catalog_notice.contains("offline"));
assert_eq!(editor.changes(), changes);
assert_eq!(editor.models.len(), 1);
assert_eq!(editor.scope, SettingsScope::Project);
}
#[test]
fn catalog_completion_keeps_draft_exclusions_and_other_tabs() {
let temp = tempfile::tempdir().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(1);
let mut app = super::super::tests::test_app(&temp, sender.clone());
let mut ui = state::MissionControlState::default();
let mut editor = SettingsEditor::loading(SettingsScope::Global);
editor.loaded(&Settings::default());
editor.fields[0].value = serde_json::json!(true);
editor
.fields
.iter_mut()
.find(|f| f.path == "providers.catalog.disabled")
.unwrap()
.value = serde_json::json!(["openai-codex/model"]);
let changes = editor.changes();
ui.modals.settings_editor = Some(editor);
app.settings_persistence.catalog = Some(
super::super::completion_worker::CompletionWorker::spawn(
"catalog-test-success",
sender,
|| {
Ok(crate::model_catalog::CatalogForUi {
entries: vec![crate::model_catalog::ModelCatalogEntry::new_codex("model")],
stale: true,
notice: Some("stale catalog".into()),
})
},
)
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(2);
while app.settings_persistence.catalog_pending() {
app.reap_settings_persistence_worker(&mut ui);
assert!(Instant::now() < deadline);
thread::yield_now();
}
let editor = ui.modals.settings_editor.as_ref().unwrap();
assert_eq!(editor.changes(), changes);
assert!(!editor.model_enabled("openai-codex/model"));
assert_eq!(editor.tab, 0);
assert!(editor.catalog_notice.contains("stale"));
assert_eq!(app.model_catalog_cache.as_ref().unwrap().entries.len(), 1);
app.active_run = true;
app.refresh_settings_catalog(&mut ui);
assert!(!app.settings_persistence.catalog_pending());
assert!(
ui.modals
.settings_editor
.as_ref()
.unwrap()
.catalog_notice
.contains("active run")
);
}
}