aboutsummaryrefslogtreecommitdiff
path: root/src/config/sound.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-21 16:30:04 +0300
committerGitHub <noreply@github.com>2026-04-21 16:30:04 +0300
commit3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch)
treec457a7cf0af271dea1a412474fad5a33c1b60be8 /src/config/sound.rs
parent2818ff492d45d17fd4f9b15478d703c6c664a8c0 (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/sound.rs')
-rw-r--r--src/config/sound.rs249
1 files changed, 249 insertions, 0 deletions
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")
+ }));
+ }
+}