magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use super::super::*;
use super::MissionControlApp;
use crate::config::{Settings, SettingsListKind, SettingsScope};

#[derive(Debug, Clone)]
pub(super) enum SettingsChange {
    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::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)
                    }
                    SettingsListKind::Models if ui.models_modal_scope() == Some(*scope) => {
                        ui.set_model_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>,
}

impl SettingsPersistenceRuntime {
    pub(super) fn is_pending(&self) -> bool {
        self.worker.is_some()
    }

    // The bounded mailbox retains the outcome independently of wake delivery.
    pub(super) fn cleanup(&mut self) -> Vec<String> {
        let mut errors = self
            .retained_failure
            .clone()
            .into_iter()
            .collect::<Vec<_>>();
        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 start_settings_persistence(
        &mut self,
        change: SettingsChange,
        ui: &mut state::MissionControlState,
    ) {
        // Input toggles optimistically. Keep the old row until durable success.
        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,
                            ..
                        }
                    ) {
                        // Acquire both locks before changing either derived setting.
                        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;
                self.settings_persistence.worker = Some(SettingsWorker {
                    request_id: self.settings_persistence.next_request_id,
                    change,
                    handle,
                });
                ui.status = "saving settings…".into();
            }
            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 Some(worker) = self.settings_persistence.worker.as_mut() else {
            return false;
        };
        let result = worker.handle.take_result();
        let change = worker.change.clone();
        if worker.handle.ready_to_reap()
            && let Some(worker) = self.settings_persistence.worker.take()
        {
            // The outcome was taken above; only reap a finished thread.
            let _ = worker.handle.join();
        }
        let Some(result) = result else {
            return false;
        };
        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::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) => {
                // A refresh failure can follow a durable write; do not retry an
                // uncertain outcome until restart has restored disk truth.
                ui.status = format!(
                    "failed to save setting: {error}; restart Mission Control before retrying"
                );
                self.settings_persistence.retained_failure = Some(error);
            }
        }
        true
    }
}