use super::super::*;
use super::{MissionControlApp, PendingSessionPreview};
use crate::{
config::{SettingsListKind, SettingsScope},
tools::MVP_TOOL_CAPABILITIES,
};
fn mcp_server_kind(config: &crate::config::McpServerConfig) -> &'static str {
match config {
crate::config::McpServerConfig::Stdio(_) => "stdio",
crate::config::McpServerConfig::Http(_) => "http",
}
}
impl MissionControlApp {
fn scope_label(&self, scope: SettingsScope) -> String {
match scope {
SettingsScope::Global => {
format!("Global ({})", self.config.paths.settings_file.display())
}
SettingsScope::Project => format!(
"Project ({})",
self.config.paths.project_settings_file.display()
),
}
}
fn showing_scope_status(&self, modal: &str, scope: SettingsScope) -> String {
format!(
"showing {modal}: {} settings",
scope.label().to_ascii_lowercase()
)
}
fn skill_rows_for_scope(
&self,
scope: SettingsScope,
) -> anyhow::Result<Vec<state::SkillToggleRow>> {
let disabled = crate::config::disabled_names_for_modal_scope(
&self.config.paths,
scope,
SettingsListKind::Skills,
)?;
Ok(self
.discovered_skills
.skills
.values()
.map(|skill| state::SkillToggleRow {
name: skill.name.clone(),
source: skill
.path
.parent()
.map(|path| path.display().to_string())
.unwrap_or_default(),
enabled: !disabled.contains(&skill.name),
})
.collect())
}
fn tool_rows_for_scope(
&self,
scope: SettingsScope,
) -> anyhow::Result<Vec<state::ToolToggleRow>> {
let disabled = crate::config::disabled_names_for_modal_scope(
&self.config.paths,
scope,
SettingsListKind::Tools,
)?;
let mut rows = MVP_TOOL_CAPABILITIES
.iter()
.map(|tool| state::ToolToggleRow {
name: tool.canonical_name().to_string(),
kind: "built-in".to_string(),
enabled: !disabled.contains(tool.canonical_name()),
})
.collect::<Vec<_>>();
if let Some(manager) = &self.mcp
&& let Ok(manager) = manager.lock()
{
rows.extend(
manager
.list_tool_definitions()
.into_iter()
.map(|(name, _)| {
let server = name
.strip_prefix("mcp__")
.and_then(|rest| rest.split("__").next())
.unwrap_or("unknown")
.to_string();
state::ToolToggleRow {
enabled: !disabled.contains(&name),
name,
kind: format!("mcp:{server}"),
}
}),
);
}
rows.sort_by(|left, right| left.name.cmp(&right.name));
Ok(rows)
}
fn subagent_rows_for_scope(
&self,
scope: SettingsScope,
) -> anyhow::Result<Vec<state::SubagentProfileToggleRow>> {
let disabled = crate::config::disabled_names_for_modal_scope(
&self.config.paths,
scope,
SettingsListKind::Subagents,
)?;
Ok(self
.subagent_profile_discovery
.profiles
.values()
.map(|profile| state::SubagentProfileToggleRow {
id: profile.id.clone(),
description: profile.description.clone(),
enabled: !disabled.contains(&profile.id),
})
.collect())
}
fn model_rows_for_scope(
&self,
scope: SettingsScope,
) -> anyhow::Result<Vec<state::ModelToggleRow>> {
let disabled = crate::config::disabled_names_for_modal_scope(
&self.config.paths,
scope,
SettingsListKind::Models,
)?;
Ok(self
.model_catalog_cache
.as_ref()
.map(|catalog| {
catalog
.entries
.iter()
.map(|entry| state::ModelToggleRow {
id: entry.id.clone(),
provider: entry.provider.clone(),
display_name: entry
.display_name
.clone()
.unwrap_or_else(|| entry.model.clone()),
enabled: !disabled.contains(&entry.id),
})
.collect()
})
.unwrap_or_default())
}
fn refresh_effective_disabled_settings(&mut self) -> anyhow::Result<()> {
let settings = crate::config::read_settings(&self.config.paths)?;
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();
*self
.disabled_tools
.lock()
.map_err(|_| anyhow::anyhow!("disabled tools lock poisoned"))? = disabled_tools;
*self
.disabled_subagent_profiles
.lock()
.map_err(|_| anyhow::anyhow!("disabled subagent profiles lock poisoned"))? =
disabled_subagents;
self.skills = filter_enabled_skills(
&self.discovered_skills,
&crate::config::disabled_skill_names_from_settings(&settings),
);
Ok(())
}
pub(crate) fn toggle_skills_settings_scope(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
let scope = ui_state
.skills_modal_scope()
.unwrap_or(SettingsScope::Global)
.toggle();
match self.skill_rows_for_scope(scope) {
Ok(rows) => {
ui_state.set_skills_modal_scope_rows(
scope,
self.scope_label(scope),
rows,
skills_modal_visible_rows(terminal_area),
);
ui_state.status = self.showing_scope_status("skills", scope);
}
Err(error) => ui_state.status = format!("failed to switch skill scope: {error}"),
}
}
pub(crate) fn toggle_tools_settings_scope(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
let scope = ui_state
.tools_modal_scope()
.unwrap_or(SettingsScope::Global)
.toggle();
match self.tool_rows_for_scope(scope) {
Ok(rows) => {
ui_state.set_tools_modal_scope_rows(
scope,
self.scope_label(scope),
rows,
tools_modal_visible_rows(terminal_area),
);
ui_state.status = self.showing_scope_status("tools", scope);
}
Err(error) => ui_state.status = format!("failed to switch tool scope: {error}"),
}
}
pub(crate) fn toggle_subagents_settings_scope(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
let scope = ui_state
.subagents_modal_scope()
.unwrap_or(SettingsScope::Global)
.toggle();
match self.subagent_rows_for_scope(scope) {
Ok(rows) => {
ui_state.set_subagents_modal_scope_rows(
scope,
self.scope_label(scope),
rows,
subagents_modal_visible_rows(terminal_area),
);
ui_state.status = self.showing_scope_status("subagents", scope);
}
Err(error) => ui_state.status = format!("failed to switch subagent scope: {error}"),
}
}
pub(crate) fn toggle_models_settings_scope(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
let scope = ui_state
.models_modal_scope()
.unwrap_or(SettingsScope::Global)
.toggle();
match self.model_rows_for_scope(scope) {
Ok(rows) => {
ui_state.set_models_modal_scope_rows(
scope,
self.scope_label(scope),
rows,
models_modal_visible_rows(terminal_area),
);
ui_state.status = self.showing_scope_status("models", scope);
}
Err(error) => ui_state.status = format!("failed to switch model scope: {error}"),
}
}
pub(crate) fn open_skills_modal(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
let scope = SettingsScope::Global;
let rows = match self.skill_rows_for_scope(scope) {
Ok(rows) => rows,
Err(error) => {
ui_state.status = format!("failed to read skill settings: {error}");
return;
}
};
ui_state.open_skills_modal_scoped(
rows,
skills_modal_visible_rows(terminal_area),
scope,
self.scope_label(scope),
);
ui_state.status = self.showing_scope_status("skills", scope);
}
pub(crate) fn open_mcp_modal(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
let settings = match crate::config::read_settings(&self.config.paths) {
Ok(settings) => settings,
Err(error) => {
ui_state.status = format!("failed to read MCP settings: {error}");
return;
}
};
let rows = settings
.mcp_servers
.iter()
.map(|(name, config)| state::McpServerToggleRow {
name: name.clone(),
kind: mcp_server_kind(config),
enabled: config.enabled(),
})
.collect();
ui_state.open_mcp_modal(rows, mcp_modal_visible_rows(terminal_area));
ui_state.status = "showing MCP servers".to_string();
}
pub(crate) fn open_tools_modal(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
let scope = SettingsScope::Global;
let rows = match self.tool_rows_for_scope(scope) {
Ok(rows) => rows,
Err(error) => {
ui_state.status = format!("failed to read tool settings: {error}");
return;
}
};
ui_state.open_tools_modal_scoped(
rows,
tools_modal_visible_rows(terminal_area),
scope,
self.scope_label(scope),
);
ui_state.status = self.showing_scope_status("tools", scope);
}
pub(crate) fn open_subagents_modal(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
let scope = SettingsScope::Global;
let rows = match self.subagent_rows_for_scope(scope) {
Ok(rows) => rows,
Err(error) => {
ui_state.status = format!("failed to read subagent settings: {error}");
return;
}
};
ui_state.open_subagents_modal_scoped(
rows,
subagents_modal_visible_rows(terminal_area),
scope,
self.scope_label(scope),
);
ui_state.status = self.showing_scope_status("subagents", scope);
}
pub(crate) fn open_models_modal(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
let scope = SettingsScope::Global;
if self
.model_catalog_cache
.as_ref()
.is_some_and(|catalog| catalog.notice.is_none())
{
let rows = match self.model_rows_for_scope(scope) {
Ok(rows) => rows,
Err(error) => {
ui_state.status = format!("failed to read model settings: {error}");
return;
}
};
ui_state.open_models_modal_scoped(
rows,
models_modal_visible_rows(terminal_area),
scope,
self.scope_label(scope),
false,
);
ui_state.status = self.showing_scope_status("models", scope);
return;
}
ui_state.open_models_modal_scoped(
Vec::new(),
models_modal_visible_rows(terminal_area),
scope,
self.scope_label(scope),
true,
);
ui_state.status = "loading models".to_string();
self.pending_model_catalog_consumer = Some(super::PendingModelCatalogConsumer::ModelsModal);
self.model_catalog_loading = true;
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::AllowStale,
);
let notice = if agg.notices.is_empty() {
None
} else {
Some(agg.notices.join("; "))
};
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: models_modal_visible_rows(terminal_area),
},
);
});
}
pub(crate) fn open_usage_modal(&mut self, ui_state: &mut state::MissionControlState) {
self.next_usage_request_id = self.next_usage_request_id.saturating_add(1);
let request_id = self.next_usage_request_id;
ui_state.pending_usage_request_id = Some(request_id);
ui_state.open_usage_modal_loading();
ui_state.status = "loading provider usage…".to_string();
let sender = self.events.clone();
thread::spawn(move || {
let result = crate::tui::usage::load_usage();
let _ = send_critical(&sender, TuiEvent::UsageLoaded { request_id, result });
});
}
pub(crate) fn open_sessions_modal(
&mut self,
ui_state: &mut state::MissionControlState,
terminal_area: ratatui::layout::Rect,
) {
match session_picker_rows(
&self.state.session_manager,
self.state.active_session_id(),
&self.state.cwd,
) {
Ok(rows) => {
ui_state.open_session_picker(rows, session_picker_visible_rows(terminal_area));
self.request_selected_session_preview(ui_state);
ui_state.status = "select session".to_string();
}
Err(error) => {
ui_state.status = format!("failed to list sessions: {error}");
}
}
}
pub(crate) fn request_selected_session_preview(
&mut self,
ui_state: &mut state::MissionControlState,
) -> bool {
let Some(session_id) = ui_state.selected_session_preview_needs_load() else {
return false;
};
self.request_session_preview(session_id)
}
fn request_session_preview(&mut self, session_id: String) -> bool {
if self
.active_session_preview
.as_ref()
.is_some_and(|pending| pending.session_id == session_id)
|| self.queued_session_preview.as_ref() == Some(&session_id)
{
return false;
}
if self.active_session_preview.is_some() {
self.queued_session_preview = Some(session_id);
return false;
}
self.spawn_session_preview_worker(session_id);
true
}
fn spawn_session_preview_worker(&mut self, session_id: String) {
self.next_session_preview_request_id =
self.next_session_preview_request_id.saturating_add(1);
let request_id = self.next_session_preview_request_id;
self.active_session_preview = Some(PendingSessionPreview {
request_id,
session_id: session_id.clone(),
});
let manager = self.state.session_manager.clone();
let sender = self.events.clone();
thread::spawn(move || {
let preview = match manager.open(&session_id) {
Ok(session) => load_session_preview(&session),
Err(error) => state::SessionPreviewState::Error(error.to_string()),
};
let _ = send_tui_event(
&sender,
TuiEvent::SessionPreviewLoaded {
request_id,
session_id,
preview,
},
);
});
}
pub(crate) fn handle_session_preview_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
) -> bool {
let mut changed = false;
for (request_id, session_id, preview) in &drain_result.session_preview_loaded {
if self
.active_session_preview
.as_ref()
.is_none_or(|pending| pending.request_id != *request_id)
{
continue;
}
self.active_session_preview = None;
changed |= ui_state.set_session_preview(session_id, preview.clone());
if let Some(queued) = self.queued_session_preview.take()
&& ui_state
.modals
.session_picker
.as_ref()
.is_some_and(|picker| {
picker
.rows
.iter()
.any(|row| row.id == queued && row.preview.is_none())
})
{
self.spawn_session_preview_worker(queued);
}
}
changed
}
pub(crate) fn refresh_selected_session_preview(
&mut self,
ui_state: &mut state::MissionControlState,
) -> bool {
self.request_selected_session_preview(ui_state)
}
pub(crate) fn set_skill_enabled(
&mut self,
name: &str,
enabled: bool,
ui_state: &mut state::MissionControlState,
) {
let scope = ui_state
.skills_modal_scope()
.unwrap_or(SettingsScope::Global);
match crate::config::set_skill_disabled_for_scope(&self.config.paths, scope, name, !enabled)
{
Ok(_) => {
if let Err(error) = self.refresh_effective_disabled_settings() {
ui_state.set_skill_row_enabled(name, !enabled);
ui_state.status = format!("failed to refresh skill settings: {error}");
return;
}
ui_state.status = if enabled {
format!(
"skill enabled in {} settings: {name}",
scope.label().to_ascii_lowercase()
)
} else {
format!(
"skill disabled in {} settings: {name}",
scope.label().to_ascii_lowercase()
)
};
}
Err(error) => {
ui_state.set_skill_row_enabled(name, !enabled);
ui_state.status = format!("failed to save skill setting: {error}");
}
}
}
pub(crate) fn set_mcp_server_enabled(
&mut self,
name: &str,
enabled: bool,
ui_state: &mut state::MissionControlState,
) {
match crate::config::set_mcp_server_enabled(&self.config.paths, name, enabled) {
Ok(()) => {
self.mcp = crate::config::read_settings(&self.config.paths)
.ok()
.and_then(|settings| {
(!settings.mcp_servers.is_empty()).then(|| {
std::sync::Arc::new(std::sync::Mutex::new(
crate::mcp::manager::McpManager::from_settings_with_paths(
&settings.mcp_servers,
Some(&self.config.paths.root),
),
))
})
});
ui_state.status = if enabled {
format!("MCP server enabled: {name}; takes effect now")
} else {
format!("MCP server disabled: {name}; takes effect now")
};
}
Err(error) => {
ui_state.set_mcp_server_row_enabled(name, !enabled);
ui_state.status = format!("failed to save MCP server setting: {error}");
}
}
}
pub(crate) fn set_tool_enabled(
&mut self,
name: &str,
enabled: bool,
ui_state: &mut state::MissionControlState,
) {
let scope = ui_state
.tools_modal_scope()
.unwrap_or(SettingsScope::Global);
match crate::config::set_tool_disabled(&self.config.paths, scope, name, !enabled) {
Ok(_) => {
if let Err(error) = self.refresh_effective_disabled_settings() {
ui_state.set_tool_row_enabled(name, !enabled);
ui_state.status = format!("failed to refresh tool settings: {error}");
return;
}
ui_state.status = if enabled {
format!(
"tool enabled in {} settings: {name}",
scope.label().to_ascii_lowercase()
)
} else {
format!(
"tool disabled in {} settings: {name}",
scope.label().to_ascii_lowercase()
)
};
}
Err(error) => {
ui_state.set_tool_row_enabled(name, !enabled);
ui_state.status = format!("failed to update tool setting: {error}");
}
}
}
pub(crate) fn set_subagent_profile_enabled(
&mut self,
id: &str,
enabled: bool,
ui_state: &mut state::MissionControlState,
) {
if !self.subagent_profile_discovery.profiles.contains_key(id) {
ui_state.set_subagent_profile_row_enabled(id, !enabled);
ui_state.status = format!("failed to update subagent profile: unknown profile {id}");
return;
}
let scope = ui_state
.subagents_modal_scope()
.unwrap_or(SettingsScope::Global);
match crate::config::set_subagent_profile_disabled(&self.config.paths, scope, id, !enabled)
{
Ok(_) => {
if let Err(error) = self.refresh_effective_disabled_settings() {
ui_state.set_subagent_profile_row_enabled(id, !enabled);
ui_state.status = format!("failed to refresh subagent settings: {error}");
return;
}
ui_state.status = if enabled {
format!(
"subagent enabled in {} settings: {id}",
scope.label().to_ascii_lowercase()
)
} else {
format!(
"subagent disabled in {} settings: {id}",
scope.label().to_ascii_lowercase()
)
};
}
Err(error) => {
ui_state.set_subagent_profile_row_enabled(id, !enabled);
ui_state.status = format!("failed to update subagent profile: {error}");
}
}
}
pub(crate) fn set_model_enabled(
&mut self,
id: &str,
enabled: bool,
ui_state: &mut state::MissionControlState,
) {
let scope = ui_state
.models_modal_scope()
.unwrap_or(SettingsScope::Global);
match crate::config::set_model_disabled_for_scope(&self.config.paths, scope, id, !enabled) {
Ok(_) => {
ui_state.status = if enabled {
format!(
"model enabled in {} settings: {id}",
scope.label().to_ascii_lowercase()
)
} else {
format!(
"model disabled in {} settings: {id}",
scope.label().to_ascii_lowercase()
)
};
}
Err(error) => {
ui_state.set_model_row_enabled(id, !enabled);
ui_state.status = format!("failed to update model setting: {error}");
}
}
}
pub(crate) fn handle_model_catalog_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
) -> bool {
let mut changed = false;
for (_request_id, result, visible_rows) in &drain_result.model_catalog_loaded {
self.model_catalog_loading = false;
match result {
Ok(catalog) => {
if let Some(entry) = catalog.entries.iter().find(|entry| {
entry.provider == ui_state.provider && entry.model == ui_state.model
}) {
let metadata = crate::thinking::CatalogThinkingMetadata {
reasoning_efforts: entry.reasoning_efforts.clone(),
supports_reasoning: entry
.supports_reasoning
.or(entry.legacy_supports_reasoning_effort),
};
let levels = crate::thinking::available_thinking_levels(
&entry.provider,
&entry.model,
Some(&metadata),
crate::thinking::capability_scope_for_provider(
&self.config.custom_providers,
&entry.provider,
),
);
ui_state.refresh_thinking_levels(self.config.thinking_level, levels);
}
self.model_catalog_cache = Some(catalog.clone());
match self.pending_model_catalog_consumer.take() {
Some(super::PendingModelCatalogConsumer::ModelsModal)
if ui_state.models_modal_visible() =>
{
let scope = ui_state
.models_modal_scope()
.unwrap_or(SettingsScope::Global);
match self.model_rows_for_scope(scope) {
Ok(rows) => {
ui_state.set_models_rows(rows, false, *visible_rows);
ui_state.status = if catalog.stale {
"model catalog loaded from stale cache".to_string()
} else {
"model catalog loaded".to_string()
};
}
Err(error) => {
ui_state.set_models_rows(Vec::new(), false, *visible_rows);
ui_state.status =
format!("failed to read model settings: {error}");
}
}
}
Some(super::PendingModelCatalogConsumer::SetModelPicker) => {
let disabled = crate::config::read_settings(&self.config.paths)
.map(|settings| {
crate::config::disabled_model_ids_from_settings(&settings)
})
.unwrap_or_default();
ui_state.open_model_picker(
catalog.entries.clone(),
disabled,
catalog.notice.clone(),
*visible_rows,
);
ui_state.status = if catalog.stale {
"model catalog loaded from stale cache".to_string()
} else {
"model catalog loaded".to_string()
};
}
_ => {}
}
}
Err(error) => {
self.pending_model_catalog_consumer = None;
if ui_state.models_modal_visible() {
ui_state.set_models_rows(Vec::new(), false, *visible_rows);
}
ui_state.record_error_transcript(error);
ui_state.status = error.clone();
}
}
changed = true;
}
changed
}
pub(crate) fn persist_thinking_level(
&mut self,
level: crate::thinking::ThinkingLevel,
ui_state: &mut state::MissionControlState,
) {
if self.active_run || self.worker.is_some() {
ui_state.refresh_thinking_levels(
self.config.thinking_level,
ui_state.thinking_levels.clone(),
);
ui_state.status = "cannot change thinking while a prompt is running".to_string();
return;
}
match crate::config::set_thinking_level(&self.config.paths, level) {
Ok(level) => {
self.config.thinking_level = level;
if let Some(config) = &mut self.state.config {
config.thinking_level = level;
}
ui_state.refresh_thinking_levels(level, ui_state.thinking_levels.clone());
ui_state.status = format!("thinking level selected: {}", ui_state.thinking_label());
}
Err(error) => {
ui_state.refresh_thinking_levels(
self.config.thinking_level,
ui_state.thinking_levels.clone(),
);
ui_state.status = format!("failed to save thinking level: {error}");
}
}
}
pub(crate) fn persist_primary_agent_selection(
&mut self,
selected_id: Option<&str>,
ui_state: &mut state::MissionControlState,
) {
match crate::config::set_selected_primary_agent(&self.config.paths, selected_id) {
Ok(Some(id)) => ui_state.status = format!("primary agent selected: {id}"),
Ok(None) => ui_state.status = "primary agent selection cleared".to_string(),
Err(error) => {
ui_state.status = format!("failed to save primary agent selection: {error}");
}
}
}
}