use anyhow::{Context, Result}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::PathBuf; use crate::notifications::{ CompletionSummaryConfig, DesktopNotificationConfig, WebhookNotificationConfig, }; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PaneLayout { #[default] Horizontal, Vertical, Grid, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RiskThresholds { pub review: f64, pub confirm: f64, pub block: f64, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct BudgetAlertThresholds { pub advisory: f64, pub warning: f64, pub critical: f64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConflictResolutionStrategy { Escalate, LastWriteWins, Merge, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct ConflictResolutionConfig { pub enabled: bool, pub strategy: ConflictResolutionStrategy, pub notify_lead: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct ComputerUseDispatchConfig { pub agent: Option, pub profile: Option, pub use_worktree: bool, pub project: Option, pub task_group: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct AgentProfileConfig { pub inherits: Option, pub agent: Option, pub model: Option, pub allowed_tools: Vec, pub disallowed_tools: Vec, pub permission_mode: Option, pub add_dirs: Vec, pub max_budget_usd: Option, pub token_budget: Option, pub append_system_prompt: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ResolvedAgentProfile { pub profile_name: String, pub agent: Option, pub model: Option, pub allowed_tools: Vec, pub disallowed_tools: Vec, pub permission_mode: Option, pub add_dirs: Vec, pub max_budget_usd: Option, pub token_budget: Option, pub append_system_prompt: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct HarnessRunnerConfig { pub program: String, pub base_args: Vec, pub project_markers: Vec, pub cwd_flag: Option, pub session_name_flag: Option, pub task_flag: Option, pub model_flag: Option, pub add_dir_flag: Option, pub include_directories_flag: Option, pub allowed_tools_flag: Option, pub disallowed_tools_flag: Option, pub permission_mode_flag: Option, pub max_budget_usd_flag: Option, pub append_system_prompt_flag: Option, pub inline_system_prompt_for_task: bool, pub env: BTreeMap, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct OrchestrationTemplateConfig { pub description: Option, pub project: Option, pub task_group: Option, pub agent: Option, pub profile: Option, pub worktree: Option, pub steps: Vec, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct OrchestrationTemplateStepConfig { pub name: Option, pub task: String, pub agent: Option, pub profile: Option, pub worktree: Option, pub project: Option, pub task_group: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoryConnectorConfig { JsonlFile(MemoryConnectorJsonlFileConfig), JsonlDirectory(MemoryConnectorJsonlDirectoryConfig), MarkdownFile(MemoryConnectorMarkdownFileConfig), MarkdownDirectory(MemoryConnectorMarkdownDirectoryConfig), DotenvFile(MemoryConnectorDotenvFileConfig), } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct MemoryConnectorJsonlFileConfig { pub path: PathBuf, pub session_id: Option, pub default_entity_type: Option, pub default_observation_type: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct MemoryConnectorJsonlDirectoryConfig { pub path: PathBuf, pub recurse: bool, pub session_id: Option, pub default_entity_type: Option, pub default_observation_type: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct MemoryConnectorMarkdownFileConfig { pub path: PathBuf, pub session_id: Option, pub default_entity_type: Option, pub default_observation_type: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct MemoryConnectorMarkdownDirectoryConfig { pub path: PathBuf, pub recurse: bool, pub session_id: Option, pub default_entity_type: Option, pub default_observation_type: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct MemoryConnectorDotenvFileConfig { pub path: PathBuf, pub session_id: Option, pub default_entity_type: Option, pub default_observation_type: Option, pub key_prefixes: Vec, pub include_keys: Vec, pub exclude_keys: Vec, pub include_safe_values: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedOrchestrationTemplate { pub template_name: String, pub description: Option, pub project: Option, pub task_group: Option, pub steps: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedOrchestrationTemplateStep { pub name: String, pub task: String, pub agent: Option, pub profile: Option, pub worktree: bool, pub project: Option, pub task_group: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { pub db_path: PathBuf, pub worktree_root: PathBuf, pub worktree_branch_prefix: String, pub max_parallel_sessions: usize, pub max_parallel_worktrees: usize, pub worktree_retention_secs: u64, pub session_timeout_secs: u64, pub heartbeat_interval_secs: u64, pub auto_terminate_stale_sessions: bool, pub default_agent: String, pub default_agent_profile: Option, pub harness_runners: BTreeMap, pub agent_profiles: BTreeMap, pub orchestration_templates: BTreeMap, pub memory_connectors: BTreeMap, pub computer_use_dispatch: ComputerUseDispatchConfig, pub auto_dispatch_unread_handoffs: bool, pub auto_dispatch_limit_per_session: usize, pub auto_create_worktrees: bool, pub auto_merge_ready_worktrees: bool, pub desktop_notifications: DesktopNotificationConfig, pub webhook_notifications: WebhookNotificationConfig, pub completion_summary_notifications: CompletionSummaryConfig, pub cost_budget_usd: f64, pub token_budget: u64, pub budget_alert_thresholds: BudgetAlertThresholds, pub conflict_resolution: ConflictResolutionConfig, pub theme: Theme, pub pane_layout: PaneLayout, pub pane_navigation: PaneNavigationConfig, pub linear_pane_size_percent: u16, pub grid_pane_size_percent: u16, pub risk_thresholds: RiskThresholds, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct PaneNavigationConfig { pub focus_sessions: String, pub focus_output: String, pub focus_metrics: String, pub focus_log: String, pub move_left: String, pub move_down: String, pub move_up: String, pub move_right: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaneNavigationAction { FocusSlot(usize), MoveLeft, MoveDown, MoveUp, MoveRight, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Theme { Dark, Light, } impl Default for Config { fn default() -> Self { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); Self { db_path: home.join(".claude").join("ecc2.db"), worktree_root: PathBuf::from("/tmp/ecc-worktrees"), worktree_branch_prefix: "ecc".to_string(), max_parallel_sessions: 8, max_parallel_worktrees: 6, worktree_retention_secs: 0, session_timeout_secs: 3600, heartbeat_interval_secs: 30, auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), default_agent_profile: None, harness_runners: BTreeMap::new(), agent_profiles: BTreeMap::new(), orchestration_templates: BTreeMap::new(), memory_connectors: BTreeMap::new(), computer_use_dispatch: ComputerUseDispatchConfig::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: DesktopNotificationConfig::default(), webhook_notifications: WebhookNotificationConfig::default(), completion_summary_notifications: CompletionSummaryConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS, conflict_resolution: ConflictResolutionConfig::default(), theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, pane_navigation: PaneNavigationConfig::default(), linear_pane_size_percent: 35, grid_pane_size_percent: 50, risk_thresholds: Self::RISK_THRESHOLDS, } } } impl Config { pub const RISK_THRESHOLDS: RiskThresholds = RiskThresholds { review: 0.35, confirm: 0.60, block: 0.85, }; pub const BUDGET_ALERT_THRESHOLDS: BudgetAlertThresholds = BudgetAlertThresholds { advisory: 0.50, warning: 0.75, critical: 0.90, }; pub fn config_path() -> PathBuf { Self::config_root().join("ecc2").join("config.toml") } pub fn cost_metrics_path(&self) -> PathBuf { self.db_path .parent() .unwrap_or_else(|| std::path::Path::new(".")) .join("metrics") .join("costs.jsonl") } pub fn tool_activity_metrics_path(&self) -> PathBuf { self.db_path .parent() .unwrap_or_else(|| std::path::Path::new(".")) .join("metrics") .join("tool-usage.jsonl") } pub fn effective_budget_alert_thresholds(&self) -> BudgetAlertThresholds { self.budget_alert_thresholds.sanitized() } pub fn computer_use_dispatch_defaults(&self) -> ResolvedComputerUseDispatchConfig { let agent = self .computer_use_dispatch .agent .clone() .unwrap_or_else(|| self.default_agent.clone()); let profile = self .computer_use_dispatch .profile .clone() .or_else(|| self.default_agent_profile.clone()); ResolvedComputerUseDispatchConfig { agent, profile, use_worktree: self.computer_use_dispatch.use_worktree, project: self.computer_use_dispatch.project.clone(), task_group: self.computer_use_dispatch.task_group.clone(), } } pub fn resolve_agent_profile(&self, name: &str) -> Result { let mut chain = Vec::new(); self.resolve_agent_profile_inner(name, &mut chain) } pub fn harness_runner(&self, harness: &str) -> Option<&HarnessRunnerConfig> { let key = harness.trim().to_ascii_lowercase(); self.harness_runners.get(&key) } pub fn resolve_orchestration_template( &self, name: &str, vars: &BTreeMap, ) -> Result { let template = self .orchestration_templates .get(name) .ok_or_else(|| anyhow::anyhow!("Unknown orchestration template: {name}"))?; if template.steps.is_empty() { anyhow::bail!("orchestration template {name} has no steps"); } let description = interpolate_optional_string(template.description.as_deref(), vars)?; let project = interpolate_optional_string(template.project.as_deref(), vars)?; let task_group = interpolate_optional_string(template.task_group.as_deref(), vars)?; let default_agent = interpolate_optional_string(template.agent.as_deref(), vars)?; let default_profile = interpolate_optional_string(template.profile.as_deref(), vars)?; if let Some(profile_name) = default_profile.as_deref() { self.resolve_agent_profile(profile_name)?; } let mut steps = Vec::with_capacity(template.steps.len()); for (index, step) in template.steps.iter().enumerate() { let task = interpolate_required_string(&step.task, vars).with_context(|| { format!( "resolve task for orchestration template {name} step {}", index + 1 ) })?; let step_name = interpolate_optional_string(step.name.as_deref(), vars)? .unwrap_or_else(|| format!("step {}", index + 1)); let agent = interpolate_optional_string( step.agent.as_deref().or(default_agent.as_deref()), vars, )?; let profile = interpolate_optional_string( step.profile.as_deref().or(default_profile.as_deref()), vars, )?; if let Some(profile_name) = profile.as_deref() { self.resolve_agent_profile(profile_name)?; } steps.push(ResolvedOrchestrationTemplateStep { name: step_name, task, agent, profile, worktree: step .worktree .or(template.worktree) .unwrap_or(self.auto_create_worktrees), project: interpolate_optional_string( step.project.as_deref().or(project.as_deref()), vars, )?, task_group: interpolate_optional_string( step.task_group.as_deref().or(task_group.as_deref()), vars, )?, }); } Ok(ResolvedOrchestrationTemplate { template_name: name.to_string(), description, project, task_group, steps, }) } fn resolve_agent_profile_inner( &self, name: &str, chain: &mut Vec, ) -> Result { if chain.iter().any(|existing| existing == name) { chain.push(name.to_string()); anyhow::bail!("agent profile inheritance cycle: {}", chain.join(" -> ")); } let profile = self .agent_profiles .get(name) .ok_or_else(|| anyhow::anyhow!("Unknown agent profile: {name}"))?; chain.push(name.to_string()); let mut resolved = if let Some(parent) = profile.inherits.as_deref() { self.resolve_agent_profile_inner(parent, chain)? } else { ResolvedAgentProfile::default() }; chain.pop(); resolved.apply(name, profile); Ok(resolved) } pub fn load() -> Result { let global_paths = Self::global_config_paths(); let project_paths = std::env::current_dir() .ok() .map(|cwd| Self::project_config_paths_from(&cwd)) .unwrap_or_default(); Self::load_from_paths(&global_paths, &project_paths) } fn load_from_paths( global_paths: &[PathBuf], project_override_paths: &[PathBuf], ) -> Result { let mut merged = toml::Value::try_from(Self::default()) .context("serialize default ECC 2.0 config for layered merge")?; for path in global_paths.iter().chain(project_override_paths.iter()) { if path.exists() { Self::merge_config_file(&mut merged, path)?; } } merged .try_into() .context("deserialize merged ECC 2.0 config") } fn config_root() -> PathBuf { dirs::config_dir().unwrap_or_else(|| { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".config") }) } fn legacy_global_config_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".claude") .join("ecc2.toml") } fn global_config_paths() -> Vec { let legacy = Self::legacy_global_config_path(); let primary = Self::config_path(); if legacy == primary { vec![primary] } else { vec![legacy, primary] } } fn project_config_paths_from(start: &std::path::Path) -> Vec { let global_paths = Self::global_config_paths(); let mut current = Some(start); while let Some(path) = current { let legacy = path.join(".claude").join("ecc2.toml"); let primary = path.join("ecc2.toml"); let mut matches = Vec::new(); if legacy.exists() && !global_paths.iter().any(|global| global == &legacy) { matches.push(legacy); } if primary.exists() && !global_paths.iter().any(|global| global == &primary) { matches.push(primary); } if !matches.is_empty() { return matches; } current = path.parent(); } Vec::new() } fn merge_config_file(base: &mut toml::Value, path: &std::path::Path) -> Result<()> { let content = std::fs::read_to_string(path) .with_context(|| format!("read ECC 2.0 config from {}", path.display()))?; let overlay: toml::Value = toml::from_str(&content) .with_context(|| format!("parse ECC 2.0 config from {}", path.display()))?; Self::merge_toml_values(base, overlay); Ok(()) } fn merge_toml_values(base: &mut toml::Value, overlay: toml::Value) { match (base, overlay) { (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => { for (key, overlay_value) in overlay_table { if let Some(base_value) = base_table.get_mut(&key) { Self::merge_toml_values(base_value, overlay_value); } else { base_table.insert(key, overlay_value); } } } (base_value, overlay_value) => *base_value = overlay_value, } } pub fn save(&self) -> Result<()> { self.save_to_path(&Self::config_path()) } pub fn save_to_path(&self, path: &std::path::Path) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let content = toml::to_string_pretty(self)?; std::fs::write(path, content)?; Ok(()) } } impl Default for PaneNavigationConfig { fn default() -> Self { Self { focus_sessions: "1".to_string(), focus_output: "2".to_string(), focus_metrics: "3".to_string(), focus_log: "4".to_string(), move_left: "ctrl-h".to_string(), move_down: "ctrl-j".to_string(), move_up: "ctrl-k".to_string(), move_right: "ctrl-l".to_string(), } } } impl PaneNavigationConfig { pub fn action_for_key(&self, key: KeyEvent) -> Option { [ (&self.focus_sessions, PaneNavigationAction::FocusSlot(1)), (&self.focus_output, PaneNavigationAction::FocusSlot(2)), (&self.focus_metrics, PaneNavigationAction::FocusSlot(3)), (&self.focus_log, PaneNavigationAction::FocusSlot(4)), (&self.move_left, PaneNavigationAction::MoveLeft), (&self.move_down, PaneNavigationAction::MoveDown), (&self.move_up, PaneNavigationAction::MoveUp), (&self.move_right, PaneNavigationAction::MoveRight), ] .into_iter() .find_map(|(binding, action)| shortcut_matches(binding, key).then_some(action)) } pub fn focus_shortcuts_label(&self) -> String { [ self.focus_sessions.as_str(), self.focus_output.as_str(), self.focus_metrics.as_str(), self.focus_log.as_str(), ] .into_iter() .map(shortcut_label) .collect::>() .join("/") } pub fn movement_shortcuts_label(&self) -> String { [ self.move_left.as_str(), self.move_down.as_str(), self.move_up.as_str(), self.move_right.as_str(), ] .into_iter() .map(shortcut_label) .collect::>() .join("/") } } fn shortcut_matches(spec: &str, key: KeyEvent) -> bool { parse_shortcut(spec) .is_some_and(|(modifiers, code)| key.modifiers == modifiers && key.code == code) } fn parse_shortcut(spec: &str) -> Option<(KeyModifiers, KeyCode)> { let normalized = spec.trim().to_ascii_lowercase().replace('+', "-"); if normalized.is_empty() { return None; } if normalized == "tab" { return Some((KeyModifiers::NONE, KeyCode::Tab)); } if normalized == "shift-tab" || normalized == "s-tab" { return Some((KeyModifiers::SHIFT, KeyCode::BackTab)); } if let Some(rest) = normalized .strip_prefix("ctrl-") .or_else(|| normalized.strip_prefix("c-")) { return parse_single_char(rest).map(|ch| (KeyModifiers::CONTROL, KeyCode::Char(ch))); } parse_single_char(&normalized).map(|ch| (KeyModifiers::NONE, KeyCode::Char(ch))) } fn parse_single_char(value: &str) -> Option { let mut chars = value.chars(); let ch = chars.next()?; (chars.next().is_none()).then_some(ch) } fn shortcut_label(spec: &str) -> String { let normalized = spec.trim().to_ascii_lowercase().replace('+', "-"); if normalized == "tab" { return "Tab".to_string(); } if normalized == "shift-tab" || normalized == "s-tab" { return "S-Tab".to_string(); } if let Some(rest) = normalized .strip_prefix("ctrl-") .or_else(|| normalized.strip_prefix("c-")) { if let Some(ch) = parse_single_char(rest) { return format!("Ctrl+{ch}"); } } normalized } impl Default for RiskThresholds { fn default() -> Self { Config::RISK_THRESHOLDS } } impl Default for BudgetAlertThresholds { fn default() -> Self { Config::BUDGET_ALERT_THRESHOLDS } } impl Default for ConflictResolutionStrategy { fn default() -> Self { Self::Escalate } } impl Default for ConflictResolutionConfig { fn default() -> Self { Self { enabled: true, strategy: ConflictResolutionStrategy::Escalate, notify_lead: true, } } } impl ResolvedAgentProfile { fn apply(&mut self, profile_name: &str, config: &AgentProfileConfig) { self.profile_name = profile_name.to_string(); if let Some(agent) = config.agent.as_ref() { self.agent = Some(agent.clone()); } if let Some(model) = config.model.as_ref() { self.model = Some(model.clone()); } merge_unique(&mut self.allowed_tools, &config.allowed_tools); merge_unique(&mut self.disallowed_tools, &config.disallowed_tools); if let Some(permission_mode) = config.permission_mode.as_ref() { self.permission_mode = Some(permission_mode.clone()); } merge_unique(&mut self.add_dirs, &config.add_dirs); if let Some(max_budget_usd) = config.max_budget_usd { self.max_budget_usd = Some(max_budget_usd); } if let Some(token_budget) = config.token_budget { self.token_budget = Some(token_budget); } self.append_system_prompt = match ( self.append_system_prompt.take(), config.append_system_prompt.as_ref(), ) { (Some(parent), Some(child)) => Some(format!("{parent}\n\n{child}")), (Some(parent), None) => Some(parent), (None, Some(child)) => Some(child.clone()), (None, None) => None, }; } } impl Default for HarnessRunnerConfig { fn default() -> Self { Self { program: String::new(), base_args: Vec::new(), project_markers: Vec::new(), cwd_flag: None, session_name_flag: None, task_flag: None, model_flag: None, add_dir_flag: None, include_directories_flag: None, allowed_tools_flag: None, disallowed_tools_flag: None, permission_mode_flag: None, max_budget_usd_flag: None, append_system_prompt_flag: None, inline_system_prompt_for_task: true, env: BTreeMap::new(), } } } impl Default for ComputerUseDispatchConfig { fn default() -> Self { Self { agent: None, profile: None, use_worktree: false, project: None, task_group: None, } } } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ResolvedComputerUseDispatchConfig { pub agent: String, pub profile: Option, pub use_worktree: bool, pub project: Option, pub task_group: Option, } fn merge_unique(base: &mut Vec, additions: &[T]) where T: Clone + PartialEq, { for value in additions { if !base.contains(value) { base.push(value.clone()); } } } fn interpolate_optional_string( value: Option<&str>, vars: &BTreeMap, ) -> Result> { value .map(|value| interpolate_required_string(value, vars)) .transpose() .map(|value| { value.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }) }) } fn interpolate_required_string(value: &str, vars: &BTreeMap) -> Result { let placeholder = Regex::new(r"\{\{\s*([A-Za-z0-9_-]+)\s*\}\}") .expect("orchestration template placeholder regex"); let mut missing = Vec::new(); let rendered = placeholder.replace_all(value, |captures: ®ex::Captures<'_>| { let key = captures .get(1) .map(|capture| capture.as_str()) .unwrap_or_default(); match vars.get(key) { Some(value) => value.to_string(), None => { missing.push(key.to_string()); String::new() } } }); if !missing.is_empty() { missing.sort(); missing.dedup(); anyhow::bail!( "missing orchestration template variable(s): {}", missing.join(", ") ); } Ok(rendered.into_owned()) } impl BudgetAlertThresholds { pub fn sanitized(self) -> Self { let values = [self.advisory, self.warning, self.critical]; let valid = values.into_iter().all(f64::is_finite) && self.advisory > 0.0 && self.advisory < self.warning && self.warning < self.critical && self.critical < 1.0; if valid { self } else { Self::default() } } } #[cfg(test)] mod tests { use super::{ BudgetAlertThresholds, ComputerUseDispatchConfig, Config, ConflictResolutionConfig, ConflictResolutionStrategy, PaneLayout, ResolvedComputerUseDispatchConfig, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::collections::BTreeMap; use std::path::PathBuf; use uuid::Uuid; #[test] fn default_includes_positive_budget_thresholds() { let config = Config::default(); assert!(config.cost_budget_usd > 0.0); assert!(config.token_budget > 0); } #[test] fn missing_budget_fields_fall_back_to_defaults() { let legacy_config = r#" db_path = "/tmp/ecc2.db" worktree_root = "/tmp/ecc-worktrees" max_parallel_sessions = 8 max_parallel_worktrees = 6 worktree_retention_secs = 0 session_timeout_secs = 3600 heartbeat_interval_secs = 30 auto_terminate_stale_sessions = false default_agent = "claude" theme = "Dark" "#; let config: Config = toml::from_str(legacy_config).unwrap(); let defaults = Config::default(); assert_eq!( config.worktree_branch_prefix, defaults.worktree_branch_prefix ); assert_eq!( config.worktree_retention_secs, defaults.worktree_retention_secs ); assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd); assert_eq!(config.token_budget, defaults.token_budget); assert_eq!( config.budget_alert_thresholds, defaults.budget_alert_thresholds ); assert_eq!(config.conflict_resolution, defaults.conflict_resolution); assert_eq!(config.pane_layout, defaults.pane_layout); assert_eq!(config.pane_navigation, defaults.pane_navigation); assert_eq!( config.linear_pane_size_percent, defaults.linear_pane_size_percent ); assert_eq!( config.grid_pane_size_percent, defaults.grid_pane_size_percent ); assert_eq!(config.risk_thresholds, defaults.risk_thresholds); assert_eq!( config.auto_dispatch_unread_handoffs, defaults.auto_dispatch_unread_handoffs ); assert_eq!( config.auto_dispatch_limit_per_session, defaults.auto_dispatch_limit_per_session ); assert_eq!(config.auto_create_worktrees, defaults.auto_create_worktrees); assert_eq!( config.auto_merge_ready_worktrees, defaults.auto_merge_ready_worktrees ); assert_eq!(config.desktop_notifications, defaults.desktop_notifications); assert_eq!(config.webhook_notifications, defaults.webhook_notifications); assert_eq!( config.auto_terminate_stale_sessions, defaults.auto_terminate_stale_sessions ); } #[test] fn default_pane_layout_is_horizontal() { assert_eq!(Config::default().pane_layout, PaneLayout::Horizontal); } #[test] fn default_pane_sizes_match_dashboard_defaults() { let config = Config::default(); assert_eq!(config.linear_pane_size_percent, 35); assert_eq!(config.grid_pane_size_percent, 50); } #[test] fn pane_layout_deserializes_from_toml() { let config: Config = toml::from_str(r#"pane_layout = "grid""#).unwrap(); assert_eq!(config.pane_layout, PaneLayout::Grid); } #[test] fn worktree_branch_prefix_deserializes_from_toml() { let config: Config = toml::from_str(r#"worktree_branch_prefix = "bots/ecc""#).unwrap(); assert_eq!(config.worktree_branch_prefix, "bots/ecc"); } #[test] fn layered_config_merges_global_and_project_overrides() { let tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4())); let legacy_global_path = tempdir.join("legacy-global.toml"); let global_path = tempdir.join("config.toml"); let project_path = tempdir.join("ecc2.toml"); std::fs::create_dir_all(&tempdir).unwrap(); std::fs::write( &legacy_global_path, r#" max_parallel_worktrees = 6 auto_create_worktrees = false [desktop_notifications] enabled = true session_completed = false "#, ) .unwrap(); std::fs::write( &global_path, r#" auto_merge_ready_worktrees = true [pane_navigation] focus_sessions = "q" move_right = "d" "#, ) .unwrap(); std::fs::write( &project_path, r#" max_parallel_worktrees = 2 auto_dispatch_limit_per_session = 9 [desktop_notifications] approval_requests = false [pane_navigation] focus_metrics = "e" "#, ) .unwrap(); let config = Config::load_from_paths(&[legacy_global_path, global_path], &[project_path]).unwrap(); assert_eq!(config.max_parallel_worktrees, 2); assert!(!config.auto_create_worktrees); assert!(config.auto_merge_ready_worktrees); assert_eq!(config.auto_dispatch_limit_per_session, 9); assert!(config.desktop_notifications.enabled); assert!(!config.desktop_notifications.session_completed); assert!(!config.desktop_notifications.approval_requests); assert_eq!(config.pane_navigation.focus_sessions, "q"); assert_eq!(config.pane_navigation.focus_metrics, "e"); assert_eq!(config.pane_navigation.move_right, "d"); let _ = std::fs::remove_dir_all(tempdir); } #[test] fn project_config_discovery_prefers_nearest_directory_and_new_path() { let tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4())); let project_root = tempdir.join("project"); let nested_dir = project_root.join("src").join("module"); std::fs::create_dir_all(project_root.join(".claude")).unwrap(); std::fs::create_dir_all(&nested_dir).unwrap(); std::fs::write(project_root.join(".claude").join("ecc2.toml"), "").unwrap(); std::fs::write(project_root.join("ecc2.toml"), "").unwrap(); let paths = Config::project_config_paths_from(&nested_dir); assert_eq!( paths, vec![ project_root.join(".claude").join("ecc2.toml"), project_root.join("ecc2.toml") ] ); let _ = std::fs::remove_dir_all(tempdir); } #[test] fn primary_config_path_uses_xdg_style_location() { let path = Config::config_path(); assert!(path.ends_with("ecc2/config.toml")); } #[test] fn pane_navigation_deserializes_from_toml() { let config: Config = toml::from_str( r#" [pane_navigation] focus_sessions = "q" focus_output = "w" focus_metrics = "e" focus_log = "r" move_left = "a" move_down = "s" move_up = "w" move_right = "d" "#, ) .unwrap(); assert_eq!(config.pane_navigation.focus_sessions, "q"); assert_eq!(config.pane_navigation.focus_output, "w"); assert_eq!(config.pane_navigation.focus_metrics, "e"); assert_eq!(config.pane_navigation.focus_log, "r"); assert_eq!(config.pane_navigation.move_left, "a"); assert_eq!(config.pane_navigation.move_down, "s"); assert_eq!(config.pane_navigation.move_up, "w"); assert_eq!(config.pane_navigation.move_right, "d"); } #[test] fn pane_navigation_matches_default_shortcuts() { let navigation = Config::default().pane_navigation; assert_eq!( navigation.action_for_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)), Some(super::PaneNavigationAction::FocusSlot(1)) ); assert_eq!( navigation.action_for_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)), Some(super::PaneNavigationAction::MoveRight) ); } #[test] fn pane_navigation_matches_custom_shortcuts() { let navigation = super::PaneNavigationConfig { focus_sessions: "q".to_string(), focus_output: "w".to_string(), focus_metrics: "e".to_string(), focus_log: "r".to_string(), move_left: "a".to_string(), move_down: "s".to_string(), move_up: "w".to_string(), move_right: "d".to_string(), }; assert_eq!( navigation.action_for_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), Some(super::PaneNavigationAction::FocusSlot(3)) ); assert_eq!( navigation.action_for_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), Some(super::PaneNavigationAction::MoveRight) ); } #[test] fn default_risk_thresholds_are_applied() { assert_eq!(Config::default().risk_thresholds, Config::RISK_THRESHOLDS); } #[test] fn default_budget_alert_thresholds_are_applied() { assert_eq!( Config::default().budget_alert_thresholds, Config::BUDGET_ALERT_THRESHOLDS ); } #[test] fn budget_alert_thresholds_deserialize_from_toml() { let config: Config = toml::from_str( r#" [budget_alert_thresholds] advisory = 0.40 warning = 0.70 critical = 0.85 "#, ) .unwrap(); assert_eq!( config.budget_alert_thresholds, BudgetAlertThresholds { advisory: 0.40, warning: 0.70, critical: 0.85, } ); assert_eq!( config.effective_budget_alert_thresholds(), config.budget_alert_thresholds ); } #[test] fn desktop_notifications_deserialize_from_toml() { let config: Config = toml::from_str( r#" [desktop_notifications] enabled = true session_completed = false session_failed = true budget_alerts = true approval_requests = false [desktop_notifications.quiet_hours] enabled = true start_hour = 21 end_hour = 7 "#, ) .unwrap(); assert!(config.desktop_notifications.enabled); assert!(!config.desktop_notifications.session_completed); assert!(config.desktop_notifications.session_failed); assert!(config.desktop_notifications.budget_alerts); assert!(!config.desktop_notifications.approval_requests); assert!(config.desktop_notifications.quiet_hours.enabled); assert_eq!(config.desktop_notifications.quiet_hours.start_hour, 21); assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7); } #[test] fn conflict_resolution_deserializes_from_toml() { let config: Config = toml::from_str( r#" [conflict_resolution] enabled = true strategy = "last_write_wins" notify_lead = false "#, ) .unwrap(); assert_eq!( config.conflict_resolution, ConflictResolutionConfig { enabled: true, strategy: ConflictResolutionStrategy::LastWriteWins, notify_lead: false, } ); } #[test] fn computer_use_dispatch_deserializes_from_toml() { let config: Config = toml::from_str( r#" [computer_use_dispatch] agent = "codex" profile = "browser" use_worktree = true project = "ops" task_group = "remote browser" "#, ) .unwrap(); assert_eq!( config.computer_use_dispatch, ComputerUseDispatchConfig { agent: Some("codex".to_string()), profile: Some("browser".to_string()), use_worktree: true, project: Some("ops".to_string()), task_group: Some("remote browser".to_string()), } ); assert_eq!( config.computer_use_dispatch_defaults(), ResolvedComputerUseDispatchConfig { agent: "codex".to_string(), profile: Some("browser".to_string()), use_worktree: true, project: Some("ops".to_string()), task_group: Some("remote browser".to_string()), } ); } #[test] fn agent_profiles_resolve_inheritance_and_defaults() { let config: Config = toml::from_str( r#" default_agent_profile = "reviewer" [agent_profiles.base] model = "sonnet" allowed_tools = ["Read"] permission_mode = "plan" add_dirs = ["docs"] append_system_prompt = "Be careful." [agent_profiles.reviewer] inherits = "base" allowed_tools = ["Edit"] disallowed_tools = ["Bash"] token_budget = 1200 append_system_prompt = "Review thoroughly." "#, ) .unwrap(); let profile = config.resolve_agent_profile("reviewer").unwrap(); assert_eq!(config.default_agent_profile.as_deref(), Some("reviewer")); assert_eq!(profile.profile_name, "reviewer"); assert_eq!(profile.model.as_deref(), Some("sonnet")); assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]); assert_eq!(profile.disallowed_tools, vec!["Bash"]); assert_eq!(profile.permission_mode.as_deref(), Some("plan")); assert_eq!(profile.add_dirs, vec![PathBuf::from("docs")]); assert_eq!(profile.token_budget, Some(1200)); assert_eq!( profile.append_system_prompt.as_deref(), Some("Be careful.\n\nReview thoroughly.") ); } #[test] fn agent_profile_resolution_rejects_inheritance_cycles() { let config: Config = toml::from_str( r#" [agent_profiles.a] inherits = "b" [agent_profiles.b] inherits = "a" "#, ) .unwrap(); let error = config .resolve_agent_profile("a") .expect_err("profile inheritance cycles must fail"); assert!(error .to_string() .contains("agent profile inheritance cycle")); } #[test] fn harness_runners_deserialize_from_toml() { let config: Config = toml::from_str( r#" [harness_runners.cursor] program = "cursor-agent" base_args = ["run"] project_markers = [".cursor", ".cursor/rules"] cwd_flag = "--cwd" session_name_flag = "--name" task_flag = "--task" model_flag = "--model" permission_mode_flag = "--permission-mode" inline_system_prompt_for_task = true [harness_runners.cursor.env] ECC_HARNESS = "cursor" "#, ) .unwrap(); let runner = config.harness_runner("cursor").expect("cursor runner"); assert_eq!(runner.program, "cursor-agent"); assert_eq!(runner.base_args, vec!["run"]); assert_eq!( runner.project_markers, vec![PathBuf::from(".cursor"), PathBuf::from(".cursor/rules")] ); assert_eq!(runner.cwd_flag.as_deref(), Some("--cwd")); assert_eq!(runner.session_name_flag.as_deref(), Some("--name")); assert_eq!(runner.task_flag.as_deref(), Some("--task")); assert_eq!(runner.model_flag.as_deref(), Some("--model")); assert_eq!( runner.permission_mode_flag.as_deref(), Some("--permission-mode") ); assert!(runner.inline_system_prompt_for_task); assert_eq!( runner.env.get("ECC_HARNESS").map(String::as_str), Some("cursor") ); } #[test] fn orchestration_templates_resolve_steps_and_interpolate_variables() { let config: Config = toml::from_str( r#" default_agent = "claude" default_agent_profile = "reviewer" [agent_profiles.reviewer] model = "sonnet" [orchestration_templates.feature_development] description = "Ship {{task}}" project = "{{project}}" task_group = "{{task_group}}" profile = "reviewer" worktree = true [[orchestration_templates.feature_development.steps]] name = "planner" task = "Plan {{task}}" agent = "claude" [[orchestration_templates.feature_development.steps]] name = "reviewer" task = "Review {{task}} in {{component}}" profile = "reviewer" worktree = false "#, ) .unwrap(); let vars = BTreeMap::from([ ("task".to_string(), "stabilize auth callback".to_string()), ("project".to_string(), "ecc-core".to_string()), ("task_group".to_string(), "auth callback".to_string()), ("component".to_string(), "billing".to_string()), ]); let template = config .resolve_orchestration_template("feature_development", &vars) .unwrap(); assert_eq!(template.template_name, "feature_development"); assert_eq!( template.description.as_deref(), Some("Ship stabilize auth callback") ); assert_eq!(template.project.as_deref(), Some("ecc-core")); assert_eq!(template.task_group.as_deref(), Some("auth callback")); assert_eq!(template.steps.len(), 2); assert_eq!(template.steps[0].name, "planner"); assert_eq!(template.steps[0].task, "Plan stabilize auth callback"); assert_eq!(template.steps[0].agent.as_deref(), Some("claude")); assert_eq!(template.steps[0].profile.as_deref(), Some("reviewer")); assert!(template.steps[0].worktree); assert_eq!( template.steps[1].task, "Review stabilize auth callback in billing" ); assert!(!template.steps[1].worktree); } #[test] fn orchestration_templates_fail_when_required_variables_are_missing() { let config: Config = toml::from_str( r#" [orchestration_templates.feature_development] [[orchestration_templates.feature_development.steps]] task = "Plan {{task}} for {{component}}" "#, ) .unwrap(); let error = config .resolve_orchestration_template( "feature_development", &BTreeMap::from([("task".to_string(), "fix retry".to_string())]), ) .expect_err("missing template variables must fail"); let error_text = format!("{error:#}"); assert!(error_text .contains("resolve task for orchestration template feature_development step 1")); assert!(error_text.contains("missing orchestration template variable(s): component")); } #[test] fn memory_connectors_deserialize_from_toml() { let config: Config = toml::from_str( r#" [memory_connectors.hermes_notes] kind = "jsonl_file" path = "/tmp/hermes-memory.jsonl" session_id = "latest" default_entity_type = "incident" default_observation_type = "external_note" "#, ) .unwrap(); let connector = config .memory_connectors .get("hermes_notes") .expect("connector should deserialize"); match connector { crate::config::MemoryConnectorConfig::JsonlFile(settings) => { assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory.jsonl")); assert_eq!(settings.session_id.as_deref(), Some("latest")); assert_eq!(settings.default_entity_type.as_deref(), Some("incident")); assert_eq!( settings.default_observation_type.as_deref(), Some("external_note") ); } _ => panic!("expected jsonl_file connector"), } } #[test] fn memory_jsonl_directory_connectors_deserialize_from_toml() { let config: Config = toml::from_str( r#" [memory_connectors.hermes_dir] kind = "jsonl_directory" path = "/tmp/hermes-memory" recurse = true default_entity_type = "incident" default_observation_type = "external_note" "#, ) .unwrap(); let connector = config .memory_connectors .get("hermes_dir") .expect("connector should deserialize"); match connector { crate::config::MemoryConnectorConfig::JsonlDirectory(settings) => { assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory")); assert!(settings.recurse); assert_eq!(settings.default_entity_type.as_deref(), Some("incident")); assert_eq!( settings.default_observation_type.as_deref(), Some("external_note") ); } _ => panic!("expected jsonl_directory connector"), } } #[test] fn memory_markdown_file_connectors_deserialize_from_toml() { let config: Config = toml::from_str( r#" [memory_connectors.workspace_note] kind = "markdown_file" path = "/tmp/hermes-memory.md" session_id = "latest" default_entity_type = "note_section" default_observation_type = "external_note" "#, ) .unwrap(); let connector = config .memory_connectors .get("workspace_note") .expect("connector should deserialize"); match connector { crate::config::MemoryConnectorConfig::MarkdownFile(settings) => { assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory.md")); assert_eq!(settings.session_id.as_deref(), Some("latest")); assert_eq!( settings.default_entity_type.as_deref(), Some("note_section") ); assert_eq!( settings.default_observation_type.as_deref(), Some("external_note") ); } _ => panic!("expected markdown_file connector"), } } #[test] fn memory_markdown_directory_connectors_deserialize_from_toml() { let config: Config = toml::from_str( r#" [memory_connectors.workspace_notes] kind = "markdown_directory" path = "/tmp/hermes-memory" recurse = true session_id = "latest" default_entity_type = "note_section" default_observation_type = "external_note" "#, ) .unwrap(); let connector = config .memory_connectors .get("workspace_notes") .expect("connector should deserialize"); match connector { crate::config::MemoryConnectorConfig::MarkdownDirectory(settings) => { assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory")); assert!(settings.recurse); assert_eq!(settings.session_id.as_deref(), Some("latest")); assert_eq!( settings.default_entity_type.as_deref(), Some("note_section") ); assert_eq!( settings.default_observation_type.as_deref(), Some("external_note") ); } _ => panic!("expected markdown_directory connector"), } } #[test] fn memory_dotenv_file_connectors_deserialize_from_toml() { let config: Config = toml::from_str( r#" [memory_connectors.hermes_env] kind = "dotenv_file" path = "/tmp/hermes.env" session_id = "latest" default_entity_type = "service_config" default_observation_type = "external_config" key_prefixes = ["STRIPE_", "PUBLIC_"] include_keys = ["PUBLIC_BASE_URL"] exclude_keys = ["STRIPE_WEBHOOK_SECRET"] include_safe_values = true "#, ) .unwrap(); let connector = config .memory_connectors .get("hermes_env") .expect("connector should deserialize"); match connector { crate::config::MemoryConnectorConfig::DotenvFile(settings) => { assert_eq!(settings.path, PathBuf::from("/tmp/hermes.env")); assert_eq!(settings.session_id.as_deref(), Some("latest")); assert_eq!( settings.default_entity_type.as_deref(), Some("service_config") ); assert_eq!( settings.default_observation_type.as_deref(), Some("external_config") ); assert_eq!(settings.key_prefixes, vec!["STRIPE_", "PUBLIC_"]); assert_eq!(settings.include_keys, vec!["PUBLIC_BASE_URL"]); assert_eq!(settings.exclude_keys, vec!["STRIPE_WEBHOOK_SECRET"]); assert!(settings.include_safe_values); } _ => panic!("expected dotenv_file connector"), } } #[test] fn completion_summary_notifications_deserialize_from_toml() { let config: Config = toml::from_str( r#" [completion_summary_notifications] enabled = true delivery = "desktop_and_tui_popup" "#, ) .unwrap(); assert!(config.completion_summary_notifications.enabled); assert_eq!( config.completion_summary_notifications.delivery, crate::notifications::CompletionSummaryDelivery::DesktopAndTuiPopup ); } #[test] fn webhook_notifications_deserialize_from_toml() { let config: Config = toml::from_str( r#" [webhook_notifications] enabled = true session_started = true session_completed = true session_failed = true budget_alerts = true approval_requests = false [[webhook_notifications.targets]] provider = "slack" url = "https://hooks.slack.test/services/abc" [[webhook_notifications.targets]] provider = "discord" url = "https://discord.test/api/webhooks/123" "#, ) .unwrap(); assert!(config.webhook_notifications.enabled); assert!(config.webhook_notifications.session_started); assert_eq!(config.webhook_notifications.targets.len(), 2); assert_eq!( config.webhook_notifications.targets[0].provider, crate::notifications::WebhookProvider::Slack ); assert_eq!( config.webhook_notifications.targets[1].provider, crate::notifications::WebhookProvider::Discord ); } #[test] fn invalid_budget_alert_thresholds_fall_back_to_defaults() { let config: Config = toml::from_str( r#" [budget_alert_thresholds] advisory = 0.80 warning = 0.70 critical = 1.10 "#, ) .unwrap(); assert_eq!( config.effective_budget_alert_thresholds(), Config::BUDGET_ALERT_THRESHOLDS ); } #[test] fn save_round_trips_automation_settings() { let path = std::env::temp_dir().join(format!("ecc2-config-{}.toml", Uuid::new_v4())); let mut config = Config::default(); config.auto_dispatch_unread_handoffs = true; config.auto_dispatch_limit_per_session = 9; config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; config.desktop_notifications.session_completed = false; config.webhook_notifications.enabled = true; config.webhook_notifications.targets = vec![crate::notifications::WebhookTarget { provider: crate::notifications::WebhookProvider::Slack, url: "https://hooks.slack.test/services/abc".to_string(), }]; config.completion_summary_notifications.delivery = crate::notifications::CompletionSummaryDelivery::TuiPopup; config.desktop_notifications.quiet_hours.enabled = true; config.desktop_notifications.quiet_hours.start_hour = 21; config.desktop_notifications.quiet_hours.end_hour = 7; config.worktree_branch_prefix = "bots/ecc".to_string(); config.budget_alert_thresholds = BudgetAlertThresholds { advisory: 0.45, warning: 0.70, critical: 0.88, }; config.conflict_resolution.strategy = ConflictResolutionStrategy::Merge; config.conflict_resolution.notify_lead = false; config.pane_navigation.focus_metrics = "e".to_string(); config.pane_navigation.move_right = "d".to_string(); config.linear_pane_size_percent = 42; config.grid_pane_size_percent = 55; config.save_to_path(&path).unwrap(); let content = std::fs::read_to_string(&path).unwrap(); let loaded: Config = toml::from_str(&content).unwrap(); assert!(loaded.auto_dispatch_unread_handoffs); assert_eq!(loaded.auto_dispatch_limit_per_session, 9); assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); assert!(!loaded.desktop_notifications.session_completed); assert!(loaded.webhook_notifications.enabled); assert_eq!(loaded.webhook_notifications.targets.len(), 1); assert_eq!( loaded.webhook_notifications.targets[0].provider, crate::notifications::WebhookProvider::Slack ); assert_eq!( loaded.completion_summary_notifications.delivery, crate::notifications::CompletionSummaryDelivery::TuiPopup ); assert!(loaded.desktop_notifications.quiet_hours.enabled); assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21); assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7); assert_eq!(loaded.worktree_branch_prefix, "bots/ecc"); assert_eq!( loaded.budget_alert_thresholds, BudgetAlertThresholds { advisory: 0.45, warning: 0.70, critical: 0.88, } ); assert_eq!( loaded.conflict_resolution.strategy, ConflictResolutionStrategy::Merge ); assert!(!loaded.conflict_resolution.notify_lead); assert_eq!(loaded.pane_navigation.focus_metrics, "e"); assert_eq!(loaded.pane_navigation.move_right, "d"); assert_eq!(loaded.linear_pane_size_percent, 42); assert_eq!(loaded.grid_pane_size_percent, 55); let _ = std::fs::remove_file(path); } }