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,
},
ToggleCodeMode {
scope: SettingsScope,
enabled: bool,
},
ToggleCodeModeTool {
scope: SettingsScope,
name: String,
enabled: bool,
},
Mcp {
name: String,
enabled: bool,
},
PrimaryAgent(Option<String>),
ResetJevCache,
}
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::ToggleCodeModeTool {
scope,
name,
enabled,
} => {
return crate::config::set_code_mode_tool_enabled(paths, *scope, name, *enabled);
}
Self::ToggleCodeMode { scope, enabled } => {
let mut changes = vec![(
"capabilities.tools.code_mode.enabled",
serde_json::json!(enabled),
)];
if !enabled {
changes.push((
"capabilities.tools.code_mode.compact_direct_tools",
serde_json::json!(false),
));
}
return crate::config::save_settings_editor(paths, *scope, &changes);
}
Self::Mcp { name, enabled } => {
crate::config::set_mcp_server_enabled(paths, name, *enabled)?
}
Self::PrimaryAgent(id) => {
crate::config::set_selected_primary_agent(paths, id.as_deref())?;
}
Self::ResetJevCache => crate::typesafe::reset_cache(paths)?,
}
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
.modals
.tools_modal
.as_ref()
.is_some_and(|modal| !modal.code_mode_tools) =>
{
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::ToggleCodeModeTool {
scope,
name,
enabled,
} if ui.tools_modal_scope() == Some(*scope)
&& ui
.modals
.tools_modal
.as_ref()
.is_some_and(|modal| modal.code_mode_tools) =>
{
ui.set_tool_row_enabled(name, if saved { *enabled } else { !enabled });
}
Self::ToggleCodeMode { scope, enabled } if ui.tools_modal_scope() == Some(*scope) => ui
.set_tool_row_enabled(
crate::code_mode::TOOL_NAME,
if saved { *enabled } else { !enabled },
),
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>>,
runtime_refresh_requested: bool,
}
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 take_runtime_refresh_requested(&mut self) -> bool {
std::mem::take(&mut self.runtime_refresh_requested)
}
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() {
match worker.join_with_timeout(WORKER_EXIT_JOIN_TIMEOUT) {
Some(Ok(_)) => {}
Some(Err(error)) => errors.push(error),
None => {
errors.push("settings catalog refresh did not finish before shutdown".into())
}
}
}
if let Some(worker) = self.worker.take() {
let request_id = worker.request_id;
match worker.handle.join_with_timeout(WORKER_EXIT_JOIN_TIMEOUT) {
Some(Ok(_)) => {}
Some(Err(error)) => errors.push(error),
None => errors.push(format!(
"settings persistence request {request_id} did not finish before shutdown"
)),
}
}
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);
let editor = ui
.modals
.settings_editor
.insert(crate::tui::settings_editor::SettingsEditor::loading(scope));
editor.tab = tab;
if let Some(catalog) = &self.model_catalog_cache {
editor.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);
}
if let Some(editor) = ui.modals.settings_editor.as_mut() {
editor.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::ResetJevCache => {
self.start_settings_persistence(SettingsChange::ResetJevCache, ui);
self.update_settings_editor_pending(ui);
}
EditorRequest::OpenTools => {
self.open_tools_modal(ui, ratatui::layout::Rect::default());
}
EditorRequest::OpenCodeModeTools => {
self.open_code_mode_tools_modal(ui, ratatui::layout::Rect::default());
}
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::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}"),
}
}
fn apply_editor_settings(
&mut self,
settings: Settings,
ui: &mut state::MissionControlState,
) -> anyhow::Result<()> {
let mut skill_roots =
crate::skills::SkillRoots::from_paths(&self.config.paths.root, &self.state.cwd);
skill_roots.additional_paths = settings.skills.additional_paths.clone();
let discovered_skills = crate::skills::discover_skills(&skill_roots);
let skills = filter_enabled_skills(
&discovered_skills,
&crate::config::disabled_skill_names_from_settings(&settings),
);
let disabled_tools = crate::config::disabled_tool_names_from_settings(&settings)
.into_iter()
.collect();
let disabled_subagents =
crate::config::disabled_subagent_profile_names_from_settings(&settings)
.into_iter()
.collect();
{
let mut tools = self
.disabled_tools
.lock()
.map_err(|_| anyhow::anyhow!("disabled tools lock poisoned"))?;
let mut subagents = self
.disabled_subagent_profiles
.lock()
.map_err(|_| anyhow::anyhow!("disabled subagent profiles lock poisoned"))?;
*tools = disabled_tools;
*subagents = disabled_subagents;
}
self.config.file_autocomplete_respects_gitignore =
settings.file_autocomplete_respects_gitignore;
self.discovered_skills = discovered_skills;
self.skills = skills;
self.settings = settings;
self.summarizer.refresh_config(&self.config, &self.settings);
ui.refresh_compaction_settings(&self.settings);
if let Some(modal) = &mut ui.conversation_modal {
modal.state.refresh_compaction_settings(&self.settings);
}
ui.subagent_card_rows = self.settings.tui.subagent_card_rows();
ui.invalidate_transcript_cache();
self.settings_persistence.runtime_refresh_requested = true;
Ok(())
}
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 { .. }
) {
match result {
Ok(settings) if matches!(change, SettingsChange::EditorLoad(_)) => {
if let Some(editor) = &mut ui.modals.settings_editor {
editor.loaded(&settings);
editor.busy = self.settings_persistence.is_pending();
ui.status = editor.notice.clone();
}
}
Ok(settings) => {
if let Some(editor) = &mut ui.modals.settings_editor {
editor.mark_saved();
}
match self.apply_editor_settings(settings, ui) {
Ok(()) => ui.status = "Settings saved and reloaded".into(),
Err(error) => {
ui.status = format!(
"Settings saved, but reload failed: {error}; restart Mission Control"
);
}
}
if let Some(editor) = &mut ui.modals.settings_editor {
editor.busy = self.settings_persistence.is_pending();
editor.notice = ui.status.clone();
}
}
Err(error) => {
if let Some(editor) = &mut ui.modals.settings_editor {
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::ToggleCodeMode { .. }
| SettingsChange::ToggleCodeModeTool { .. } => {
if let Some(editor) = &mut ui.modals.settings_editor {
editor.sync_code_mode_settings(&settings.tools.code_mode);
}
self.settings.tools.code_mode = settings.tools.code_mode;
self.settings_persistence.runtime_refresh_requested = true;
}
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::ResetJevCache => "Jev cache reset".into(),
SettingsChange::Mcp { .. } => {
"MCP server setting saved; takes effect next magi-code launch".into()
}
SettingsChange::PrimaryAgent(Some(id)) => {
format!("primary agent selected: {id}")
}
SettingsChange::PrimaryAgent(None) => "primary agent selection cleared".into(),
SettingsChange::ToggleCodeMode { scope, enabled } => format!(
"tool {} in {} settings: {}",
if enabled { "enabled" } else { "disabled" },
scope.label().to_ascii_lowercase(),
crate::code_mode::TOOL_NAME,
),
SettingsChange::ToggleCodeModeTool {
scope,
name,
enabled,
} => format!(
"Code Mode tool {} in {} settings: {name}",
if enabled { "enabled" } else { "disabled" },
scope.label().to_ascii_lowercase()
),
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
}
}