diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-21 16:30:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 16:30:04 +0300 |
| commit | 3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch) | |
| tree | c457a7cf0af271dea1a412474fad5a33c1b60be8 /src/config | |
| parent | 2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff) | |
feat: add persistent server-client sessions
Make herdr persistent by default.
Launching herdr now starts or reattaches to a background session
server. Clients can detach and reattach while panes and agent
processes keep running. Session mode now supports multi-client attach,
auto-detect startup, and a thin-client/headless-server split.
This also refactors the large app, ui, input, pane, config,
workspace, and persistence modules into smaller focused submodules,
while preserving behavior and colocating tests with the code they
exercise.
Upgrade notes:
- persistence mode is now the default
- in-app quit detaches the current client instead of stopping the server
- use `herdr server stop` to stop the background session
- use `--no-session` for the old single-process behavior
- default socket paths now live under the config directory
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/io.rs | 226 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 1089 | ||||
| -rw-r--r-- | src/config/model.rs | 216 | ||||
| -rw-r--r-- | src/config/sound.rs | 249 | ||||
| -rw-r--r-- | src/config/theme.rs | 163 |
5 files changed, 1943 insertions, 0 deletions
diff --git a/src/config/io.rs b/src/config/io.rs new file mode 100644 index 00000000..3bc18c4b --- /dev/null +++ b/src/config/io.rs @@ -0,0 +1,226 @@ +use std::path::{Path, PathBuf}; + +use tracing::warn; + +use super::{model::LoadedConfig, Config, LiveKeybindConfig, CONFIG_PATH_ENV_VAR}; + +pub fn app_dir_name() -> &'static str { + if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + } +} + +pub fn config_dir() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(dir).join(app_dir_name()) + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(format!(".config/{}", app_dir_name())) + } else { + PathBuf::from(format!("/tmp/{}", app_dir_name())) + } +} + +impl Config { + pub fn load() -> LoadedConfig { + let path = config_path(); + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(content) => match toml::from_str::<Config>(&content) { + Ok(config) => { + let diagnostics = config.collect_diagnostics(); + return LoadedConfig { + config, + diagnostics, + }; + } + Err(err) => { + warn!(err = %err, "config parse error, using defaults"); + return LoadedConfig { + config: Self::default(), + diagnostics: vec![format!("config parse error: {err}; using defaults")], + }; + } + }, + Err(err) => { + warn!(err = %err, "config read error, using defaults"); + return LoadedConfig { + config: Self::default(), + diagnostics: vec![format!("config read error: {err}; using defaults")], + }; + } + } + } + LoadedConfig { + config: Self::default(), + diagnostics: Vec::new(), + } + } +} + +pub(super) fn resolve_config_relative_path(path: &Path) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + + config_path() + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(path) +} + +pub fn save_onboarding_choices(sound_enabled: bool, toast_enabled: bool) -> std::io::Result<()> { + let path = config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = std::fs::read_to_string(&path).unwrap_or_default(); + let content = upsert_top_level_bool(&content, "onboarding", false); + let content = upsert_section_bool(&content, "ui.sound", "enabled", sound_enabled); + let content = upsert_section_bool(&content, "ui.toast", "enabled", toast_enabled); + std::fs::write(path, content) +} + +pub fn config_path() -> PathBuf { + if let Ok(path) = std::env::var(CONFIG_PATH_ENV_VAR) { + return PathBuf::from(path); + } + config_dir().join("config.toml") +} + +pub fn load_live_keybinds() -> Result<LiveKeybindConfig, Vec<String>> { + let path = config_path(); + if !path.exists() { + return Config::default().live_keybinds(); + } + + let content = std::fs::read_to_string(&path).map_err(|err| { + vec![format!( + "config read error: {err}; keeping current keybinds" + )] + })?; + let config = toml::from_str::<Config>(&content).map_err(|err| { + vec![format!( + "config parse error: {err}; keeping current keybinds" + )] + })?; + config.live_keybinds() +} + +pub(super) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { + let replacement = format!("{key} = {value}"); + let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect(); + let mut in_section = false; + + for line in &mut lines { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_section = true; + continue; + } + if in_section { + continue; + } + if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) { + *line = replacement.clone(); + return lines.join("\n") + "\n"; + } + } + + if lines.is_empty() { + format!("{replacement}\n") + } else { + format!("{replacement}\n{}\n", lines.join("\n").trim_end()) + } +} + +/// Write a key = value pair in a TOML section (creates section if missing). +pub fn upsert_section_value(content: &str, section: &str, key: &str, value: &str) -> String { + upsert_section_raw(content, section, key, value) +} + +pub fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) -> String { + upsert_section_raw(content, section, key, &value.to_string()) +} + +fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String { + let header = format!("[{section}]"); + let assignment = format!("{key} = {value}"); + let lines: Vec<&str> = content.lines().collect(); + let mut result = Vec::new(); + let mut i = 0; + let mut found_section = false; + let mut inserted = false; + + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim(); + + if trimmed == header { + found_section = true; + result.push(line.to_string()); + i += 1; + + while i < lines.len() { + let current = lines[i]; + let current_trimmed = current.trim(); + if current_trimmed.starts_with('[') && current_trimmed.ends_with(']') { + if !inserted { + result.push(assignment.clone()); + inserted = true; + } + break; + } + + if current_trimmed.starts_with(&format!("{key} ")) + || current_trimmed.starts_with(&format!("{key}=")) + { + result.push(assignment.clone()); + inserted = true; + } else { + result.push(current.to_string()); + } + i += 1; + } + + continue; + } + + result.push(line.to_string()); + i += 1; + } + + if !found_section { + if !result.is_empty() && !result.last().is_some_and(|line| line.trim().is_empty()) { + result.push(String::new()); + } + result.push(header); + result.push(assignment); + } else if !inserted { + result.push(assignment); + } + + result.join("\n") + "\n" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upsert_top_level_bool_replaces_existing_value() { + let content = "onboarding = true\n[keys]\nprefix = \"ctrl+b\"\n"; + let updated = upsert_top_level_bool(content, "onboarding", false); + assert!(updated.contains("onboarding = false")); + assert!(!updated.contains("onboarding = true")); + } + + #[test] + fn upsert_section_bool_adds_missing_section() { + let updated = upsert_section_bool("", "ui.toast", "enabled", true); + assert!(updated.contains("[ui.toast]")); + assert!(updated.contains("enabled = true")); + } +} diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs new file mode 100644 index 00000000..07c682ba --- /dev/null +++ b/src/config/keybinds.rs @@ -0,0 +1,1089 @@ +use crossterm::event::{KeyCode, KeyModifiers}; +use serde::Deserialize; +use tracing::warn; + +use super::Config; + +#[derive(Debug)] +pub struct LiveKeybindConfig { + pub prefix: (KeyCode, KeyModifiers), + pub keybinds: Keybinds, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum CommandKeybindType { + #[default] + Shell, + Pane, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct CommandKeybindConfig { + /// Navigate-mode key that runs a command after pressing the prefix key. + pub key: String, + /// Command executed either in the background shell or inside a pane. + pub command: String, + /// Command execution mode. Default: "shell". + #[serde(rename = "type")] + pub action_type: CommandKeybindType, +} + +impl Default for CommandKeybindConfig { + fn default() -> Self { + Self { + key: String::new(), + command: String::new(), + action_type: CommandKeybindType::Shell, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustomCommandAction { + Shell, + Pane, +} + +#[derive(Debug, Clone)] +pub struct CustomCommandKeybind { + pub key: (KeyCode, KeyModifiers), + pub label: String, + pub command: String, + pub action: CustomCommandAction, +} + +/// Parsed keybinds for navigate mode actions. +#[derive(Debug, Clone)] +pub struct Keybinds { + pub new_workspace: (KeyCode, KeyModifiers), + pub new_workspace_label: String, + pub rename_workspace: (KeyCode, KeyModifiers), + pub rename_workspace_label: String, + pub close_workspace: (KeyCode, KeyModifiers), + pub close_workspace_label: String, + pub detach: Option<(KeyCode, KeyModifiers)>, + pub detach_label: Option<String>, + pub previous_workspace: Option<(KeyCode, KeyModifiers)>, + pub previous_workspace_label: Option<String>, + pub next_workspace: Option<(KeyCode, KeyModifiers)>, + pub next_workspace_label: Option<String>, + pub new_tab: (KeyCode, KeyModifiers), + pub new_tab_label: String, + pub rename_tab: Option<(KeyCode, KeyModifiers)>, + pub rename_tab_label: Option<String>, + pub previous_tab: Option<(KeyCode, KeyModifiers)>, + pub previous_tab_label: Option<String>, + pub next_tab: Option<(KeyCode, KeyModifiers)>, + pub next_tab_label: Option<String>, + pub close_tab: Option<(KeyCode, KeyModifiers)>, + pub close_tab_label: Option<String>, + pub focus_pane_left: Option<(KeyCode, KeyModifiers)>, + pub focus_pane_left_label: Option<String>, + pub focus_pane_down: Option<(KeyCode, KeyModifiers)>, + pub focus_pane_down_label: Option<String>, + pub focus_pane_up: Option<(KeyCode, KeyModifiers)>, + pub focus_pane_up_label: Option<String>, + pub focus_pane_right: Option<(KeyCode, KeyModifiers)>, + pub focus_pane_right_label: Option<String>, + pub split_vertical: (KeyCode, KeyModifiers), + pub split_vertical_label: String, + pub split_horizontal: (KeyCode, KeyModifiers), + pub split_horizontal_label: String, + pub close_pane: (KeyCode, KeyModifiers), + pub close_pane_label: String, + pub fullscreen: (KeyCode, KeyModifiers), + pub fullscreen_label: String, + pub resize_mode: (KeyCode, KeyModifiers), + pub resize_mode_label: String, + pub toggle_sidebar: (KeyCode, KeyModifiers), + pub toggle_sidebar_label: String, + pub custom_commands: Vec<CustomCommandKeybind>, +} + +impl Config { + pub(super) fn validated_keybinds( + &self, + ) -> ( + Option<String>, + (KeyCode, KeyModifiers), + Vec<String>, + Keybinds, + ) { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + enum BindingScope { + Navigate, + TerminalDirect, + } + + #[derive(Clone)] + struct RequiredBinding<'a> { + scope: BindingScope, + field: &'a str, + label: String, + default_label: &'a str, + value: (KeyCode, KeyModifiers), + default: (KeyCode, KeyModifiers), + } + + struct OptionalBinding { + scope: BindingScope, + field: &'static str, + value: Option<(KeyCode, KeyModifiers)>, + label: Option<String>, + } + + #[derive(Default)] + struct BindingRegistry { + seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>, + } + + impl BindingRegistry { + fn register( + &mut self, + scope: BindingScope, + binding: (KeyCode, KeyModifiers), + field: &str, + ) -> Option<String> { + self.seen + .insert((scope, binding.0, binding.1), field.to_string()) + } + + fn conflict( + &self, + scope: BindingScope, + binding: (KeyCode, KeyModifiers), + ) -> Option<&str> { + self.seen + .get(&(scope, binding.0, binding.1)) + .map(String::as_str) + } + + fn reserve_if_unbound( + &mut self, + scope: BindingScope, + binding: (KeyCode, KeyModifiers), + field: &str, + ) { + self.seen + .entry((scope, binding.0, binding.1)) + .or_insert_with(|| field.to_string()); + } + } + + fn required_binding<'a>( + scope: BindingScope, + field: &'a str, + configured_label: &'a str, + default_label: &'a str, + default: (KeyCode, KeyModifiers), + diagnostics: &mut Vec<String>, + ) -> RequiredBinding<'a> { + let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default); + let label = if let Some(diag) = diag { + diagnostics.push(diag); + default_label.to_string() + } else { + configured_label.to_string() + }; + RequiredBinding { + scope, + field, + label, + default_label, + value, + default, + } + } + + fn optional_binding( + scope: BindingScope, + field: &'static str, + configured_label: &str, + diagnostics: &mut Vec<String>, + ) -> OptionalBinding { + if configured_label.trim().is_empty() { + return OptionalBinding { + scope, + field, + value: None, + label: None, + }; + } + match parse_key_combo(configured_label) { + Some(value) => OptionalBinding { + scope, + field, + value: Some(value), + label: Some(configured_label.to_string()), + }, + None => { + let diag = format!( + "invalid keybinding: {field} = {:?}; disabling binding", + configured_label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + OptionalBinding { + scope, + field, + value: None, + label: None, + } + } + } + } + + let mut diagnostics = Vec::new(); + let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( + &self.keys.prefix, + "keys.prefix", + (KeyCode::Char('b'), KeyModifiers::CONTROL), + ); + if let Some(diag) = &prefix_diag { + warn!(message = %diag, "config diagnostic"); + } + + let mut bindings = vec![ + required_binding( + BindingScope::Navigate, + "keys.new_workspace", + &self.keys.new_workspace, + "n", + (KeyCode::Char('n'), KeyModifiers::empty()), + &mut diagnostics, + ), + required_binding( + BindingScope::Navigate, + "keys.rename_workspace", + &self.keys.rename_workspace, + "shift+n", + (KeyCode::Char('n'), KeyModifiers::SHIFT), + &mut diagnostics, + ), + required_binding( + BindingScope::Navigate, + "keys.close_workspace", + &self.keys.close_workspace, + "shift+d", + (KeyCode::Char('d'), KeyModifiers::SHIFT), + &mut diagnostics, + ), + required_binding( + BindingScope::Navigate, + "keys.new_tab", + &self.keys.new_tab, + "c", + (KeyCode::Char('c'), KeyModifiers::empty()), + &mut diagnostics, + ), + required_binding( + BindingScope::Navigate, + "keys.split_vertical", + &self.keys.split_vertical, + "v", + (KeyCode::Char('v'), KeyModifiers::empty()), + &mut diagnostics, + ), + required_binding( + BindingScope::Navigate, + "keys.split_horizontal", + &self.keys.split_horizontal, + "-", + (KeyCode::Char('-'), KeyModifiers::empty()), + &mut diagnostics, + ), + required_binding( + BindingScope::Navigate, + "keys.close_pane", + &self.keys.close_pane, + "x", + (KeyCode::Char('x'), KeyModifiers::empty()), + &mut diagnostics, + ), + required_binding( + BindingScope::Navigate, + "keys.fullscreen", + &self.keys.fullscreen, + "f", + (KeyCode::Char('f'), KeyModifiers::empty()), + &mut diagnostics, + ), + required_binding( + BindingScope::Navigate, + "keys.resize_mode", + &self.keys.resize_mode, + "r", + (KeyCode::Char('r'), KeyModifiers::empty()), + &mut diagnostics, + ), + required_binding( + BindingScope::Navigate, + "keys.toggle_sidebar", + &self.keys.toggle_sidebar, + "b", + (KeyCode::Char('b'), KeyModifiers::empty()), + &mut diagnostics, + ), + ]; + + let mut optional_bindings = vec![ + optional_binding( + BindingScope::Navigate, + "keys.detach", + &self.keys.detach, + &mut diagnostics, + ), + optional_binding( + BindingScope::Navigate, + "keys.previous_workspace", + &self.keys.previous_workspace, + &mut diagnostics, + ), + optional_binding( + BindingScope::Navigate, + "keys.next_workspace", + &self.keys.next_workspace, + &mut diagnostics, + ), + optional_binding( + BindingScope::Navigate, + "keys.rename_tab", + &self.keys.rename_tab, + &mut diagnostics, + ), + optional_binding( + BindingScope::Navigate, + "keys.previous_tab", + &self.keys.previous_tab, + &mut diagnostics, + ), + optional_binding( + BindingScope::Navigate, + "keys.next_tab", + &self.keys.next_tab, + &mut diagnostics, + ), + optional_binding( + BindingScope::Navigate, + "keys.close_tab", + &self.keys.close_tab, + &mut diagnostics, + ), + optional_binding( + BindingScope::TerminalDirect, + "keys.focus_pane_left", + &self.keys.focus_pane_left, + &mut diagnostics, + ), + optional_binding( + BindingScope::TerminalDirect, + "keys.focus_pane_down", + &self.keys.focus_pane_down, + &mut diagnostics, + ), + optional_binding( + BindingScope::TerminalDirect, + "keys.focus_pane_up", + &self.keys.focus_pane_up, + &mut diagnostics, + ), + optional_binding( + BindingScope::TerminalDirect, + "keys.focus_pane_right", + &self.keys.focus_pane_right, + &mut diagnostics, + ), + ]; + + let mut registry = BindingRegistry::default(); + for binding in &mut bindings { + if let Some(first_field) = registry.conflict(binding.scope, binding.value) { + let diag = format!( + "duplicate keybinding: {} conflicts with {}; using default {}", + binding.field, first_field, binding.default_label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + binding.value = binding.default; + binding.label = binding.default_label.to_string(); + } + registry.register(binding.scope, binding.value, binding.field); + } + + for binding in &mut optional_bindings { + let Some(value) = binding.value else { + continue; + }; + if let Some(first_field) = registry.conflict(binding.scope, value) { + let diag = format!( + "duplicate keybinding: {} conflicts with {}; disabling binding", + binding.field, first_field + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + binding.value = None; + binding.label = None; + continue; + } + registry.register(binding.scope, value, binding.field); + } + + registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix"); + for (field, binding) in [ + ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())), + ( + "navigate.open_workspace", + (KeyCode::Enter, KeyModifiers::empty()), + ), + ( + "navigate.settings", + (KeyCode::Char('s'), KeyModifiers::empty()), + ), + ( + "navigate.keybind_help", + (KeyCode::Char('?'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_up", + (KeyCode::Up, KeyModifiers::empty()), + ), + ( + "navigate.workspace_down", + (KeyCode::Down, KeyModifiers::empty()), + ), + ( + "navigate.focus_left", + (KeyCode::Char('h'), KeyModifiers::empty()), + ), + ( + "navigate.focus_down", + (KeyCode::Char('j'), KeyModifiers::empty()), + ), + ( + "navigate.focus_up", + (KeyCode::Char('k'), KeyModifiers::empty()), + ), + ( + "navigate.focus_right", + (KeyCode::Char('l'), KeyModifiers::empty()), + ), + ( + "navigate.arrow_left", + (KeyCode::Left, KeyModifiers::empty()), + ), + ( + "navigate.arrow_right", + (KeyCode::Right, KeyModifiers::empty()), + ), + ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())), + ( + "navigate.tab_prev", + (KeyCode::BackTab, KeyModifiers::empty()), + ), + ( + "navigate.workspace_1", + (KeyCode::Char('1'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_2", + (KeyCode::Char('2'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_3", + (KeyCode::Char('3'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_4", + (KeyCode::Char('4'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_5", + (KeyCode::Char('5'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_6", + (KeyCode::Char('6'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_7", + (KeyCode::Char('7'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_8", + (KeyCode::Char('8'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_9", + (KeyCode::Char('9'), KeyModifiers::empty()), + ), + ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())), + ] { + registry.reserve_if_unbound(BindingScope::Navigate, binding, field); + } + + let mut custom_commands = Vec::new(); + for (index, command) in self.keys.command.iter().enumerate() { + let key_field = format!("keys.command[{index}].key"); + let command_field = format!("keys.command[{index}].command"); + + if command.command.trim().is_empty() { + let diag = + format!("empty custom command: {command_field}; disabling custom command"); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + continue; + } + + let Some(binding) = parse_key_combo(&command.key) else { + let diag = format!( + "invalid keybinding: {} = {:?}; disabling custom command", + key_field, command.key + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + continue; + }; + + if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) { + let diag = format!( + "duplicate custom keybinding: {} conflicts with {}; disabling custom command", + key_field, first_field + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + continue; + } + + registry.register(BindingScope::Navigate, binding, &key_field); + let action = match command.action_type { + CommandKeybindType::Shell => CustomCommandAction::Shell, + CommandKeybindType::Pane => CustomCommandAction::Pane, + }; + custom_commands.push(CustomCommandKeybind { + key: binding, + label: format_key_combo(binding), + command: command.command.clone(), + action, + }); + } + + let keybinds = Keybinds { + new_workspace: bindings[0].value, + new_workspace_label: bindings[0].label.clone(), + rename_workspace: bindings[1].value, + rename_workspace_label: bindings[1].label.clone(), + close_workspace: bindings[2].value, + close_workspace_label: bindings[2].label.clone(), + detach: optional_bindings[0].value, + detach_label: optional_bindings[0].label.clone(), + previous_workspace: optional_bindings[1].value, + previous_workspace_label: optional_bindings[1].label.clone(), + next_workspace: optional_bindings[2].value, + next_workspace_label: optional_bindings[2].label.clone(), + new_tab: bindings[3].value, + new_tab_label: bindings[3].label.clone(), + rename_tab: optional_bindings[3].value, + rename_tab_label: optional_bindings[3].label.clone(), + previous_tab: optional_bindings[4].value, + previous_tab_label: optional_bindings[4].label.clone(), + next_tab: optional_bindings[5].value, + next_tab_label: optional_bindings[5].label.clone(), + close_tab: optional_bindings[6].value, + close_tab_label: optional_bindings[6].label.clone(), + focus_pane_left: optional_bindings[7].value, + focus_pane_left_label: optional_bindings[7].label.clone(), + focus_pane_down: optional_bindings[8].value, + focus_pane_down_label: optional_bindings[8].label.clone(), + focus_pane_up: optional_bindings[9].value, + focus_pane_up_label: optional_bindings[9].label.clone(), + focus_pane_right: optional_bindings[10].value, + focus_pane_right_label: optional_bindings[10].label.clone(), + split_vertical: bindings[4].value, + split_vertical_label: bindings[4].label.clone(), + split_horizontal: bindings[5].value, + split_horizontal_label: bindings[5].label.clone(), + close_pane: bindings[6].value, + close_pane_label: bindings[6].label.clone(), + fullscreen: bindings[7].value, + fullscreen_label: bindings[7].label.clone(), + resize_mode: bindings[8].value, + resize_mode_label: bindings[8].label.clone(), + toggle_sidebar: bindings[9].value, + toggle_sidebar_label: bindings[9].label.clone(), + custom_commands, + }; + + (prefix_diag, prefix, diagnostics, keybinds) + } +} + +pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { + let (code, modifiers) = binding; + let mut parts = Vec::new(); + if modifiers.contains(KeyModifiers::CONTROL) { + parts.push("ctrl".to_string()); + } + if modifiers.contains(KeyModifiers::ALT) { + parts.push("alt".to_string()); + } + if modifiers.contains(KeyModifiers::SHIFT) { + parts.push("shift".to_string()); + } + + let key = match code { + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Char(c) => c.to_string(), + KeyCode::Enter => "enter".to_string(), + KeyCode::Esc => "esc".to_string(), + KeyCode::Tab => "tab".to_string(), + KeyCode::BackTab => "shift+tab".to_string(), + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Left => "left".to_string(), + KeyCode::Right => "right".to_string(), + KeyCode::Up => "up".to_string(), + KeyCode::Down => "down".to_string(), + KeyCode::F(n) => format!("f{n}"), + _ => format!("{:?}", code).to_lowercase(), + }; + + if matches!(code, KeyCode::BackTab) { + return if parts.is_empty() { + key + } else { + format!("{}+tab", parts.join("+")) + }; + } + + parts.push(key); + parts.join("+") +} + +pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { + let parts: Vec<&str> = s.split('+').collect(); + let mut modifiers = KeyModifiers::empty(); + let mut key_str: Option<&str> = None; + + for part in &parts { + let trimmed = part.trim(); + match trimmed.to_lowercase().as_str() { + "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, + "shift" => modifiers |= KeyModifiers::SHIFT, + "alt" | "meta" => modifiers |= KeyModifiers::ALT, + _ if trimmed.is_empty() => return None, + _ => { + if key_str.is_some() { + return None; + } + key_str = Some(trimmed); + } + } + } + + let key_str = key_str?; + + let lower = key_str.to_lowercase(); + let code = match lower.as_str() { + "space" | " " => KeyCode::Char(' '), + "enter" | "return" => KeyCode::Enter, + "esc" | "escape" => KeyCode::Esc, + "tab" => KeyCode::Tab, + "backspace" | "bs" => KeyCode::Backspace, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + s if s.len() == 1 => { + let ch = key_str.chars().next().unwrap(); + if ch.is_ascii_uppercase() { + modifiers |= KeyModifiers::SHIFT; + KeyCode::Char(ch.to_ascii_lowercase()) + } else { + KeyCode::Char(ch) + } + } + s if s.starts_with('f') => s[1..].parse::<u8>().ok().map(KeyCode::F)?, + _ => return None, + }; + + Some((code, modifiers)) +} + +fn parse_key_combo_with_diagnostic( + s: &str, + field: &str, + fallback: (KeyCode, KeyModifiers), +) -> ((KeyCode, KeyModifiers), Option<String>) { + match parse_key_combo(s) { + Some(binding) => (binding, None), + None => { + let diag = format!("invalid keybinding: {field} = {s:?}; using fallback"); + warn!(message = %diag, "config diagnostic"); + (fallback, Some(diag)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + #[test] + fn parse_simple_char() { + assert_eq!( + parse_key_combo("v"), + Some((KeyCode::Char('v'), KeyModifiers::empty())) + ); + } + + #[test] + fn parse_ctrl_combo() { + assert_eq!( + parse_key_combo("ctrl+b"), + Some((KeyCode::Char('b'), KeyModifiers::CONTROL)) + ); + } + + #[test] + fn parse_special_key() { + assert_eq!( + parse_key_combo("enter"), + Some((KeyCode::Enter, KeyModifiers::empty())) + ); + assert_eq!( + parse_key_combo("tab"), + Some((KeyCode::Tab, KeyModifiers::empty())) + ); + assert_eq!( + parse_key_combo("esc"), + Some((KeyCode::Esc, KeyModifiers::empty())) + ); + assert_eq!( + parse_key_combo("left"), + Some((KeyCode::Left, KeyModifiers::empty())) + ); + assert_eq!( + parse_key_combo("alt+right"), + Some((KeyCode::Right, KeyModifiers::ALT)) + ); + } + + #[test] + fn parse_ctrl_shift() { + assert_eq!( + parse_key_combo("ctrl+shift+a"), + Some(( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + )) + ); + } + + #[test] + fn parse_f_key() { + assert_eq!( + parse_key_combo("f5"), + Some((KeyCode::F(5), KeyModifiers::empty())) + ); + } + + #[test] + fn parse_punctuation_key() { + assert_eq!( + parse_key_combo("ctrl+`"), + Some((KeyCode::Char('`'), KeyModifiers::CONTROL)) + ); + } + + #[test] + fn uppercase_char_implies_shift() { + assert_eq!( + parse_key_combo("D"), + Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) + ); + } + + #[test] + fn explicit_shift_and_uppercase_do_not_double_apply_shift() { + assert_eq!( + parse_key_combo("shift+D"), + Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) + ); + } + + #[test] + fn invalid_keybinding_is_rejected() { + assert_eq!(parse_key_combo("ctrl+foo+bar"), None); + assert_eq!(parse_key_combo("ctrl+"), None); + } + + #[test] + fn default_keybinds_parse() { + let config = Config::default(); + let kb = config.keybinds(); + assert_eq!(kb.new_workspace.0, KeyCode::Char('n')); + assert_eq!( + kb.rename_workspace, + (KeyCode::Char('n'), KeyModifiers::SHIFT) + ); + assert_eq!( + kb.close_workspace, + (KeyCode::Char('d'), KeyModifiers::SHIFT) + ); + assert_eq!(kb.detach, None); + assert_eq!(kb.split_vertical.0, KeyCode::Char('v')); + assert_eq!(kb.split_horizontal.0, KeyCode::Char('-')); + assert_eq!(kb.close_pane.0, KeyCode::Char('x')); + assert_eq!(kb.fullscreen.0, KeyCode::Char('f')); + assert_eq!(kb.resize_mode.0, KeyCode::Char('r')); + assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b')); + assert!(kb.custom_commands.is_empty()); + } + + #[test] + fn custom_keybinds_from_toml() { + let toml = r#" +[keys] +prefix = "ctrl+a" +new_workspace = "c" +rename_workspace = "shift+r" +close_workspace = "ctrl+d" +split_vertical = "s" +split_horizontal = "shift+s" +close_pane = "ctrl+w" +fullscreen = "z" +resize_mode = "ctrl+r" +toggle_sidebar = "tab" +focus_pane_left = "alt+h" +focus_pane_right = "alt+right" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let (code, mods) = config.prefix_key(); + assert_eq!(code, KeyCode::Char('a')); + assert_eq!(mods, KeyModifiers::CONTROL); + + let kb = config.keybinds(); + assert_eq!( + kb.new_workspace, + (KeyCode::Char('c'), KeyModifiers::empty()) + ); + assert_eq!( + kb.rename_workspace, + (KeyCode::Char('r'), KeyModifiers::SHIFT) + ); + assert_eq!( + kb.close_workspace, + (KeyCode::Char('d'), KeyModifiers::CONTROL) + ); + assert_eq!(kb.split_vertical.0, KeyCode::Char('s')); + assert_eq!( + kb.split_horizontal, + (KeyCode::Char('s'), KeyModifiers::SHIFT) + ); + assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!(kb.fullscreen.0, KeyCode::Char('z')); + assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty())); + assert_eq!( + kb.focus_pane_left, + Some((KeyCode::Char('h'), KeyModifiers::ALT)) + ); + assert_eq!( + kb.focus_pane_right, + Some((KeyCode::Right, KeyModifiers::ALT)) + ); + assert_eq!(kb.focus_pane_down, None); + assert_eq!(kb.focus_pane_up, None); + } + + #[test] + fn uppercase_keybind_from_toml_flows_into_shift_combo() { + let toml = r#" +[keys] +close_pane = "X" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let kb = config.keybinds(); + assert_eq!(kb.close_pane, (KeyCode::Char('x'), KeyModifiers::SHIFT)); + } + + #[test] + fn invalid_keybinding_produces_diagnostic_and_falls_back() { + let toml = r#" +[keys] +rename_workspace = "wat" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics + .iter() + .any(|d| d.contains("keys.rename_workspace"))); + assert_eq!( + kb.rename_workspace, + (KeyCode::Char('n'), KeyModifiers::SHIFT) + ); + assert_eq!(kb.rename_workspace_label, "shift+n"); + } + + #[test] + fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() { + let toml = r#" +[keys] +new_workspace = "g" +rename_workspace = "g" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics + .iter() + .any(|d| d.contains("duplicate keybinding"))); + assert_eq!( + kb.new_workspace, + (KeyCode::Char('g'), KeyModifiers::empty()) + ); + assert_eq!( + kb.rename_workspace, + (KeyCode::Char('n'), KeyModifiers::SHIFT) + ); + assert_eq!(kb.rename_workspace_label, "shift+n"); + } + + #[test] + fn duplicate_optional_keybinding_is_disabled_with_diagnostic() { + let toml = r#" +[keys] +new_workspace = "g" +rename_tab = "g" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics + .iter() + .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding"))); + assert_eq!( + kb.new_workspace, + (KeyCode::Char('g'), KeyModifiers::empty()) + ); + assert_eq!(kb.rename_tab, None); + } + + #[test] + fn custom_command_keybinds_parse_from_toml() { + let toml = r#" +[[keys.command]] +key = "g" +command = "echo hi" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let kb = config.keybinds(); + + assert_eq!(kb.custom_commands.len(), 1); + assert_eq!( + kb.custom_commands[0].key, + (KeyCode::Char('g'), KeyModifiers::empty()) + ); + assert_eq!(kb.custom_commands[0].label, "g"); + assert_eq!(kb.custom_commands[0].command, "echo hi"); + assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell); + } + + #[test] + fn pane_custom_command_keybinds_parse_from_toml() { + let toml = r#" +[[keys.command]] +key = "g" +type = "pane" +command = "lazygit" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let kb = config.keybinds(); + + assert_eq!(kb.custom_commands.len(), 1); + assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane); + } + + #[test] + fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() { + let toml = r#" +[keys] +new_workspace = "g" + +[[keys.command]] +key = "g" +command = "echo hi" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics.iter().any(|d| { + d.contains("duplicate custom keybinding") + && d.contains("keys.command[0].key") + && d.contains("keys.new_workspace") + })); + assert!(kb.custom_commands.is_empty()); + } + + #[test] + fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() { + let toml = r#" +[[keys.command]] +key = "q" +command = "echo hi" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics.iter().any(|d| { + d.contains("duplicate custom keybinding") + && d.contains("keys.command[0].key") + && d.contains("navigate.quit") + })); + assert!(kb.custom_commands.is_empty()); + } + + #[test] + fn live_keybinds_reject_invalid_keybinding() { + let config: Config = toml::from_str( + r#" +[keys] +rename_workspace = "wat" +"#, + ) + .unwrap(); + + let diagnostics = config.live_keybinds().unwrap_err(); + assert!(diagnostics + .iter() + .any(|d| d.contains("keys.rename_workspace"))); + } + + #[test] + fn live_keybinds_ignore_non_key_diagnostics() { + let config: Config = toml::from_str( + r#" +[keys] +new_workspace = "g" + +[ui.sound] +done_path = "sounds/missing.mp3" +"#, + ) + .unwrap(); + + let live = config.live_keybinds().unwrap(); + assert_eq!( + live.keybinds.new_workspace, + (KeyCode::Char('g'), KeyModifiers::empty()) + ); + } +} diff --git a/src/config/model.rs b/src/config/model.rs new file mode 100644 index 00000000..70eede5a --- /dev/null +++ b/src/config/model.rs @@ -0,0 +1,216 @@ +use serde::Deserialize; + +use super::{CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ToastConfig { + pub enabled: bool, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct Config { + pub onboarding: Option<bool>, + pub theme: ThemeConfig, + pub keys: KeysConfig, + pub ui: UiConfig, + pub advanced: AdvancedConfig, +} + +#[derive(Debug)] +pub struct LoadedConfig { + pub config: Config, + pub diagnostics: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct KeysConfig { + /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc"). + pub prefix: String, + /// Create a new workspace. Default: "n" + pub new_workspace: String, + /// Rename the selected workspace. Default: "shift+n" + pub rename_workspace: String, + /// Close the selected workspace. Default: "shift+d" + pub close_workspace: String, + /// Optional explicit detach shortcut in server/client mode. Unset by default. + pub detach: String, + /// Select the previous workspace. Unset by default. + pub previous_workspace: String, + /// Select the next workspace. Unset by default. + pub next_workspace: String, + /// Create a new tab in the active workspace. Default: "c" + pub new_tab: String, + /// Rename the active tab. Unset by default. + pub rename_tab: String, + /// Select the previous tab. Unset by default. + pub previous_tab: String, + /// Select the next tab. Unset by default. + pub next_tab: String, + /// Close the active tab. Unset by default. + pub close_tab: String, + /// Focus the pane to the left in terminal mode. Unset by default. + pub focus_pane_left: String, + /// Focus the pane below in terminal mode. Unset by default. + pub focus_pane_down: String, + /// Focus the pane above in terminal mode. Unset by default. + pub focus_pane_up: String, + /// Focus the pane to the right in terminal mode. Unset by default. + pub focus_pane_right: String, + /// Split pane vertically (side by side). Default: "v" + pub split_vertical: String, + /// Split pane horizontally (stacked). Default: "-" + pub split_horizontal: String, + /// Close the focused pane. Default: "x" + pub close_pane: String, + /// Toggle fullscreen for the focused pane. Default: "f" + pub fullscreen: String, + /// Enter resize mode. Default: "r" + pub resize_mode: String, + /// Toggle sidebar collapse. Default: "b" + pub toggle_sidebar: String, + /// Prefix-mode custom command bindings. + pub command: Vec<CommandKeybindConfig>, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct UiConfig { + pub sidebar_width: u16, + /// Ask for confirmation before closing a workspace. Default: true. + pub confirm_close: bool, + /// Accent color for highlights, borders, and navigation UI. + /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)). + pub accent: String, + /// Optional visual toast notifications for background workspace events. + pub toast: ToastConfig, + /// Play sounds when agents change state in background workspaces. + pub sound: SoundConfig, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct AdvancedConfig { + /// Allow launching herdr inside an existing herdr pane. Default: false. + pub allow_nested: bool, + /// Maximum scrollback buffer size in bytes retained per pane terminal. Default: 10000000. + #[serde(alias = "scrollback_lines")] + pub scrollback_limit_bytes: usize, +} + +impl Default for KeysConfig { + fn default() -> Self { + Self { + prefix: "ctrl+b".into(), + new_workspace: "n".into(), + rename_workspace: "shift+n".into(), + close_workspace: "shift+d".into(), + detach: "".into(), + previous_workspace: "".into(), + next_workspace: "".into(), + new_tab: "c".into(), + rename_tab: "".into(), + previous_tab: "".into(), + next_tab: "".into(), + close_tab: "".into(), + focus_pane_left: "".into(), + focus_pane_down: "".into(), + focus_pane_up: "".into(), + focus_pane_right: "".into(), + split_vertical: "v".into(), + split_horizontal: "-".into(), + close_pane: "x".into(), + fullscreen: "f".into(), + resize_mode: "r".into(), + toggle_sidebar: "b".into(), + command: Vec::new(), + } + } +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + sidebar_width: 26, + confirm_close: true, + accent: "cyan".into(), + toast: ToastConfig::default(), + sound: SoundConfig::default(), + } + } +} + +impl Default for ToastConfig { + fn default() -> Self { + Self { enabled: false } + } +} + +impl Default for AdvancedConfig { + fn default() -> Self { + Self { + allow_nested: false, + scrollback_limit_bytes: DEFAULT_SCROLLBACK_LIMIT_BYTES, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toast_config_parses() { + let toml = r#" +[ui.toast] +enabled = true +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.ui.toast.enabled); + } + + #[test] + fn missing_onboarding_shows_setup() { + let config = Config::default(); + assert!(config.should_show_onboarding()); + } + + #[test] + fn onboarding_false_skips_setup() { + let config: Config = toml::from_str("onboarding = false").unwrap(); + assert!(!config.should_show_onboarding()); + } + + #[test] + fn advanced_defaults_include_scrollback_limit_bytes() { + let config = Config::default(); + assert_eq!( + config.advanced.scrollback_limit_bytes, + DEFAULT_SCROLLBACK_LIMIT_BYTES + ); + } + + #[test] + fn advanced_config_parses() { + let toml = r#" +[advanced] +allow_nested = true +scrollback_limit_bytes = 12345 +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.advanced.allow_nested); + assert_eq!(config.advanced.scrollback_limit_bytes, 12345); + } + + #[test] + fn advanced_legacy_scrollback_lines_alias_parses() { + let toml = r#" +[advanced] +scrollback_lines = 12345 +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.advanced.scrollback_limit_bytes, 12345); + } +} diff --git a/src/config/sound.rs b/src/config/sound.rs new file mode 100644 index 00000000..063a521b --- /dev/null +++ b/src/config/sound.rs @@ -0,0 +1,249 @@ +use std::path::PathBuf; + +use serde::Deserialize; + +use crate::detect::Agent; + +use super::io::resolve_config_relative_path; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct SoundConfig { + pub enabled: bool, + /// Optional mp3 file path used for all notification sounds. + /// Relative paths are resolved from the config file's directory. + pub path: Option<PathBuf>, + /// Optional mp3 file path for "done" notifications. + /// Relative paths are resolved from the config file's directory. + pub done_path: Option<PathBuf>, + /// Optional mp3 file path for "request" notifications. + /// Relative paths are resolved from the config file's directory. + pub request_path: Option<PathBuf>, + pub agents: AgentSoundOverrides, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct AgentSoundOverrides { + pub pi: AgentSoundSetting, + pub claude: AgentSoundSetting, + pub codex: AgentSoundSetting, + pub gemini: AgentSoundSetting, + pub cursor: AgentSoundSetting, + pub cline: AgentSoundSetting, + pub open_code: AgentSoundSetting, + pub github_copilot: AgentSoundSetting, + pub kimi: AgentSoundSetting, + pub droid: AgentSoundSetting, + pub amp: AgentSoundSetting, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSoundSetting { + #[default] + Default, + On, + Off, +} + +impl SoundConfig { + pub fn allows(&self, agent: Option<Agent>) -> bool { + if !self.enabled { + return false; + } + + !matches!(self.agents.for_agent(agent), AgentSoundSetting::Off) + } + + pub fn path_for(&self, sound: crate::sound::Sound) -> Option<PathBuf> { + let path = match sound { + crate::sound::Sound::Done => self.done_path.as_ref().or(self.path.as_ref()), + crate::sound::Sound::Request => self.request_path.as_ref().or(self.path.as_ref()), + }?; + + Some(resolve_config_relative_path(path)) + } + + pub fn diagnostics(&self) -> Vec<String> { + let mut diagnostics = Vec::new(); + for (field, path) in [ + ("ui.sound.path", self.path.as_ref()), + ("ui.sound.done_path", self.done_path.as_ref()), + ("ui.sound.request_path", self.request_path.as_ref()), + ] { + let Some(path) = path else { + continue; + }; + + let resolved = resolve_config_relative_path(path); + if resolved + .extension() + .and_then(|ext| ext.to_str()) + .is_none_or(|ext: &str| !ext.eq_ignore_ascii_case("mp3")) + { + diagnostics.push(format!( + "unsupported sound file format: {field} = {} resolves to {}; expected an mp3 file; using default sound", + path.display(), + resolved.display() + )); + continue; + } + + if !resolved.exists() { + diagnostics.push(format!( + "missing sound file: {field} = {} resolves to {}; using default sound", + path.display(), + resolved.display() + )); + } else if !resolved.is_file() { + diagnostics.push(format!( + "invalid sound file: {field} = {} resolves to {}; using default sound", + path.display(), + resolved.display() + )); + } + } + diagnostics + } +} + +impl AgentSoundOverrides { + pub fn for_agent(&self, agent: Option<Agent>) -> AgentSoundSetting { + match agent { + Some(Agent::Pi) => self.pi, + Some(Agent::Claude) => self.claude, + Some(Agent::Codex) => self.codex, + Some(Agent::Gemini) => self.gemini, + Some(Agent::Cursor) => self.cursor, + Some(Agent::Cline) => self.cline, + Some(Agent::OpenCode) => self.open_code, + Some(Agent::GithubCopilot) => self.github_copilot, + Some(Agent::Kimi) => self.kimi, + Some(Agent::Droid) => self.droid, + Some(Agent::Amp) => self.amp, + None => AgentSoundSetting::Default, + } + } +} + +impl Default for SoundConfig { + fn default() -> Self { + Self { + enabled: true, + path: None, + done_path: None, + request_path: None, + agents: AgentSoundOverrides::default(), + } + } +} + +impl Default for AgentSoundOverrides { + fn default() -> Self { + Self { + pi: AgentSoundSetting::Default, + claude: AgentSoundSetting::Default, + codex: AgentSoundSetting::Default, + gemini: AgentSoundSetting::Default, + cursor: AgentSoundSetting::Default, + cline: AgentSoundSetting::Default, + open_code: AgentSoundSetting::Default, + github_copilot: AgentSoundSetting::Default, + kimi: AgentSoundSetting::Default, + droid: AgentSoundSetting::Off, + amp: AgentSoundSetting::Default, + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::config::{config_path, Config}; + + #[test] + fn sound_table_config_parses() { + let toml = r#" +[ui.sound] +enabled = true +path = "sounds/all.mp3" +done_path = "sounds/done.mp3" +request_path = "/tmp/request.mp3" + +[ui.sound.agents] +droid = "off" +claude = "on" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.ui.sound.enabled); + assert_eq!(config.ui.sound.path, Some(PathBuf::from("sounds/all.mp3"))); + assert_eq!( + config.ui.sound.done_path, + Some(PathBuf::from("sounds/done.mp3")) + ); + assert_eq!( + config.ui.sound.request_path, + Some(PathBuf::from("/tmp/request.mp3")) + ); + assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off); + assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On); + assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default); + } + + #[test] + fn sound_path_resolution_prefers_specific_over_global() { + let config: Config = toml::from_str( + r#" +[ui.sound] +path = "sounds/all.mp3" +done_path = "sounds/done.mp3" +"#, + ) + .unwrap(); + + let config_root = config_path().parent().unwrap().to_path_buf(); + assert_eq!( + config.ui.sound.path_for(crate::sound::Sound::Done), + Some(config_root.join("sounds/done.mp3")) + ); + assert_eq!( + config.ui.sound.path_for(crate::sound::Sound::Request), + Some(config_root.join("sounds/all.mp3")) + ); + } + + #[test] + fn missing_sound_file_produces_diagnostic() { + let config: Config = toml::from_str( + r#" +[ui.sound] +done_path = "sounds/missing.mp3" +"#, + ) + .unwrap(); + + let diagnostics = config.collect_diagnostics(); + assert!(diagnostics.iter().any( + |diag| diag.contains("ui.sound.done_path") && diag.contains("using default sound") + )); + } + + #[test] + fn non_mp3_sound_file_produces_diagnostic() { + let config: Config = toml::from_str( + r#" +[ui.sound] +path = "sounds/notification.wav" +"#, + ) + .unwrap(); + + let diagnostics = config.collect_diagnostics(); + assert!(diagnostics.iter().any(|diag| { + diag.contains("ui.sound.path") && diag.contains("expected an mp3 file") + })); + } +} diff --git a/src/config/theme.rs b/src/config/theme.rs new file mode 100644 index 00000000..9d641862 --- /dev/null +++ b/src/config/theme.rs @@ -0,0 +1,163 @@ +use serde::Deserialize; +use tracing::warn; + +/// Theme configuration: pick a built-in or override individual tokens. +/// +/// ```toml +/// [theme] +/// name = "tokyo-night" # built-in: catppuccin, tokyo-night, dracula, nord, etc. +/// +/// [theme.custom] # override individual tokens on top of the base +/// accent = "#f5c2e7" +/// red = "#ff6188" +/// ``` +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct ThemeConfig { + /// Built-in theme name. Default: "catppuccin". + pub name: Option<String>, + /// Custom overrides — applied on top of the selected base theme. + pub custom: Option<CustomThemeColors>, +} + +/// Per-token color overrides. All fields optional — only set what you want to change. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct CustomThemeColors { + pub accent: Option<String>, + pub panel_bg: Option<String>, + pub surface0: Option<String>, + pub surface1: Option<String>, + pub surface_dim: Option<String>, + pub overlay0: Option<String>, + pub overlay1: Option<String>, + pub text: Option<String>, + pub subtext0: Option<String>, + pub mauve: Option<String>, + pub green: Option<String>, + pub yellow: Option<String>, + pub red: Option<String>, + pub blue: Option<String>, + pub teal: Option<String>, + pub peach: Option<String>, +} + +/// Parse a color string into a ratatui Color. +/// Supports: hex (#rrggbb, #rgb), named colors, rgb(r,g,b), and reset aliases. +pub fn parse_color(s: &str) -> ratatui::style::Color { + use ratatui::style::Color; + let s = s.trim().to_lowercase(); + + match s.as_str() { + "reset" | "default" | "none" | "transparent" => return Color::Reset, + _ => {} + } + + if let Some(hex) = s.strip_prefix('#') { + if hex.len() == 6 { + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + ) { + return Color::Rgb(r, g, b); + } + } else if hex.len() == 3 { + let chars: Vec<u8> = hex + .chars() + .filter_map(|c| u8::from_str_radix(&c.to_string(), 16).ok()) + .collect(); + if chars.len() == 3 { + return Color::Rgb(chars[0] * 17, chars[1] * 17, chars[2] * 17); + } + } + } + + if let Some(inner) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) { + let parts: Vec<&str> = inner.split(',').collect(); + if parts.len() == 3 { + if let (Ok(r), Ok(g), Ok(b)) = ( + parts[0].trim().parse::<u8>(), + parts[1].trim().parse::<u8>(), + parts[2].trim().parse::<u8>(), + ) { + return Color::Rgb(r, g, b); + } + } + } + + match s.as_str() { + "black" => Color::Black, + "red" => Color::Red, + "green" => Color::Green, + "yellow" => Color::Yellow, + "blue" => Color::Blue, + "magenta" | "purple" => Color::Magenta, + "cyan" => Color::Cyan, + "white" => Color::White, + "gray" | "grey" => Color::Gray, + "darkgray" | "darkgrey" => Color::DarkGray, + "lightred" => Color::LightRed, + "lightgreen" => Color::LightGreen, + "lightyellow" => Color::LightYellow, + "lightblue" => Color::LightBlue, + "lightmagenta" => Color::LightMagenta, + "lightcyan" => Color::LightCyan, + _ => { + warn!(color = s, "unknown color, defaulting to cyan"); + Color::Cyan + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + #[test] + fn theme_name_parses() { + let toml = r#" +[theme] +name = "dracula" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.theme.name.as_deref(), Some("dracula")); + } + + #[test] + fn parse_color_accepts_reset_aliases() { + use ratatui::style::Color; + + for value in ["reset", "default", "none", "transparent"] { + assert_eq!(parse_color(value), Color::Reset, "value: {value}"); + } + } + + #[test] + fn theme_custom_overrides_parse() { + let toml = r##" +[theme] +name = "nord" + +[theme.custom] +panel_bg = "#1e1e2e" +accent = "#ff79c6" +red = "rgb(255, 85, 85)" +"##; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.theme.name.as_deref(), Some("nord")); + let custom = config.theme.custom.as_ref().unwrap(); + assert_eq!(custom.panel_bg.as_deref(), Some("#1e1e2e")); + assert_eq!(custom.accent.as_deref(), Some("#ff79c6")); + assert_eq!(custom.red.as_deref(), Some("rgb(255, 85, 85)")); + assert!(custom.green.is_none()); + } + + #[test] + fn theme_defaults_when_missing() { + let config: Config = toml::from_str("").unwrap(); + assert!(config.theme.name.is_none()); + assert!(config.theme.custom.is_none()); + } +} |
