From f8c4d6a5f1bd4bcaee43ba3e1373465cd960cdc3 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 28 Mar 2026 00:32:35 +0300 Subject: [PATCH] feat: add per-agent sound overrides --- README.md | 9 ++- src/app/actions.rs | 11 ++- src/app/input.rs | 12 +--- src/app/mod.rs | 2 +- src/app/state.rs | 9 ++- src/config.rs | 151 +++++++++++++++++++++++++++++++++++++++--- src/detect.rs | 87 ++++++++++++------------ src/events.rs | 8 +-- src/input.rs | 24 ++++--- src/layout.rs | 15 +++-- src/main.rs | 21 ++++-- src/pane.rs | 7 +- src/persist.rs | 9 ++- src/platform/macos.rs | 10 +-- src/pty_callbacks.rs | 11 ++- src/selection.rs | 2 - src/sound.rs | 7 +- src/ui.rs | 76 +++++++++++---------- src/update.rs | 44 +++++++----- src/workspace.rs | 5 +- 20 files changed, 350 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index 8cc5e871..3139ed53 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,16 @@ accent = "cyan" # ask for confirmation before closing a workspace confirm_close = true +[ui.sound] # play sounds when agents change state in background workspaces # a chime when an agent finishes, an alert when one needs input -sound = true +enabled = true + +[ui.sound.agents] +# per-agent override: default | on | off +# droid is muted by default +claude = "default" +droid = "off" ``` ### environment variables diff --git a/src/app/actions.rs b/src/app/actions.rs index c73ba646..98159fcc 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -62,7 +62,8 @@ impl AppState { pub fn resize_pane(&mut self, direction: NavDirection) { if let Some(first) = self.view.pane_infos.first() { let area = self - .view.pane_infos + .view + .pane_infos .iter() .fold(first.rect, |acc, p| acc.union(p.rect)); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { @@ -168,7 +169,7 @@ impl AppState { } // Sound notifications for background state changes - if self.sound && !is_active_ws && state != prev_state { + if self.sound.allows(agent) && !is_active_ws && state != prev_state { match state { AgentState::Idle if prev_state != AgentState::Idle => { crate::sound::play(crate::sound::Sound::Done); @@ -390,7 +391,11 @@ mod tests { let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); // First set it to Busy - state.workspaces[1].panes.get_mut(&bg_pane_id).unwrap().state = AgentState::Busy; + state.workspaces[1] + .panes + .get_mut(&bg_pane_id) + .unwrap() + .state = AgentState::Busy; // Now transition to Idle while in background state.handle_app_event(AppEvent::StateChanged { diff --git a/src/app/input.rs b/src/app/input.rs index b9a52bda..c91722f7 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -373,12 +373,7 @@ impl AppState { mouse.row - info.inner_rect.y, mouse.column - info.inner_rect.x, ); - self.selection = Some(Selection::anchor( - info.id, - row, - col, - info.inner_rect, - )); + self.selection = Some(Selection::anchor(info.id, row, col, info.inner_rect)); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { if ws.layout.focused() != info.id { @@ -434,10 +429,7 @@ impl AppState { if self.drag.take().is_some() { // Drag ended } else { - let was_click = self - .selection - .as_ref() - .is_some_and(|s| s.was_just_click()); + let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); if was_click { self.selection = None; } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index 47aaa3da..b3fab4a9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -85,7 +85,7 @@ impl App { sidebar_collapsed: false, confirm_close: config.ui.confirm_close, accent: crate::config::parse_color(&config.ui.accent), - sound: config.ui.sound, + sound: config.ui.sound.clone(), keybinds: config.keybinds(), }; diff --git a/src/app/state.rs b/src/app/state.rs index 440bfc17..92d729e8 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,5 +1,5 @@ +use crate::config::{Keybinds, SoundConfig}; use crossterm::event::{KeyCode, KeyModifiers}; -use crate::config::Keybinds; use ratatui::layout::{Direction, Rect}; use ratatui::style::Color; @@ -69,7 +69,7 @@ pub struct AppState { pub sidebar_collapsed: bool, pub confirm_close: bool, pub accent: Color, - pub sound: bool, + pub sound: SoundConfig, pub keybinds: Keybinds, } @@ -120,7 +120,10 @@ impl AppState { sidebar_collapsed: false, confirm_close: true, accent: Color::Cyan, - sound: true, + sound: SoundConfig { + enabled: false, + ..SoundConfig::default() + }, keybinds: Keybinds { split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), diff --git a/src/config.rs b/src/config.rs index 704d176c..fe14ca8e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,8 @@ use crossterm::event::{KeyCode, KeyModifiers}; use serde::Deserialize; use tracing::warn; +use crate::detect::Agent; + #[derive(Debug, Default, Deserialize)] #[serde(default)] pub struct Config { @@ -36,7 +38,68 @@ pub struct UiConfig { /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)). pub accent: String, /// Play sounds when agents change state in background workspaces. - pub sound: bool, + pub sound: SoundConfig, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct SoundConfig { + pub enabled: bool, + 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) -> bool { + if !self.enabled { + return false; + } + + !matches!(self.agents.for_agent(agent), AgentSoundSetting::Off) + } +} + +impl AgentSoundOverrides { + pub fn for_agent(&self, agent: Option) -> 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 KeysConfig { @@ -57,7 +120,34 @@ impl Default for UiConfig { sidebar_width: 26, confirm_close: true, accent: "cyan".into(), - sound: true, + sound: SoundConfig::default(), + } + } +} + +impl Default for SoundConfig { + fn default() -> Self { + Self { + enabled: true, + 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, } } } @@ -220,32 +310,53 @@ mod tests { #[test] fn parse_simple_char() { - assert_eq!(parse_key_combo("v"), (KeyCode::Char('v'), KeyModifiers::empty())); + assert_eq!( + parse_key_combo("v"), + (KeyCode::Char('v'), KeyModifiers::empty()) + ); } #[test] fn parse_ctrl_combo() { - assert_eq!(parse_key_combo("ctrl+s"), (KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert_eq!( + parse_key_combo("ctrl+s"), + (KeyCode::Char('s'), KeyModifiers::CONTROL) + ); } #[test] fn parse_special_key() { - assert_eq!(parse_key_combo("enter"), (KeyCode::Enter, KeyModifiers::empty())); - assert_eq!(parse_key_combo("tab"), (KeyCode::Tab, KeyModifiers::empty())); - assert_eq!(parse_key_combo("esc"), (KeyCode::Esc, KeyModifiers::empty())); + assert_eq!( + parse_key_combo("enter"), + (KeyCode::Enter, KeyModifiers::empty()) + ); + assert_eq!( + parse_key_combo("tab"), + (KeyCode::Tab, KeyModifiers::empty()) + ); + assert_eq!( + parse_key_combo("esc"), + (KeyCode::Esc, KeyModifiers::empty()) + ); } #[test] fn parse_ctrl_shift() { assert_eq!( parse_key_combo("ctrl+shift+a"), - (KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT) + ( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + ) ); } #[test] fn parse_f_key() { - assert_eq!(parse_key_combo("f5"), (KeyCode::F(5), KeyModifiers::empty())); + assert_eq!( + parse_key_combo("f5"), + (KeyCode::F(5), KeyModifiers::empty()) + ); } #[test] @@ -275,8 +386,28 @@ fullscreen = "z" let kb = config.keybinds(); assert_eq!(kb.split_vertical.0, KeyCode::Char('s')); - assert_eq!(kb.split_horizontal, (KeyCode::Char('s'), KeyModifiers::SHIFT)); + 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')); } + + #[test] + fn sound_table_config_parses() { + let toml = r#" +[ui.sound] +enabled = true + +[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.agents.droid, AgentSoundSetting::Off); + assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On); + assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default); + } } diff --git a/src/detect.rs b/src/detect.rs index f1e0cb42..07a9c682 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -269,9 +269,7 @@ fn detect_cline(content: &str) -> AgentState { return AgentState::Waiting; } // [act mode] or [plan mode] followed by "yes" - if (lower.contains("[act mode]") || lower.contains("[plan mode]")) - && lower.contains("yes") - { + if (lower.contains("[act mode]") || lower.contains("[plan mode]")) && lower.contains("yes") { return AgentState::Waiting; } @@ -440,9 +438,7 @@ fn has_selection_prompt(content: &str) -> bool { let trimmed = line.trim(); if trimmed.starts_with('❯') { // Check if there's a digit followed by a dot nearby - if trimmed.chars().any(|c| c.is_ascii_digit()) - && trimmed.contains('.') - { + if trimmed.chars().any(|c| c.is_ascii_digit()) && trimmed.contains('.') { return true; } } @@ -481,9 +477,7 @@ fn has_spinner_activity(content: &str) -> bool { fn has_cursor_spinner(content: &str) -> bool { for line in content.lines() { let trimmed = line.trim(); - if (trimmed.starts_with('⬡') || trimmed.starts_with('⬢')) - && trimmed.contains("ing") - { + if (trimmed.starts_with('⬡') || trimmed.starts_with('⬢')) && trimmed.contains("ing") { return true; } } @@ -525,7 +519,11 @@ pub fn foreground_job(child_pid: u32) -> Option fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String { let effective = process.argv0.as_deref().unwrap_or(&process.name); let lower_effective = effective.to_lowercase(); - let lower_cmdline = process.cmdline.as_deref().unwrap_or_default().to_lowercase(); + let lower_cmdline = process + .cmdline + .as_deref() + .unwrap_or_default() + .to_lowercase(); if lower_effective == "node" && (lower_cmdline.contains("/codex") || lower_cmdline.contains("@openai/codex")) @@ -613,7 +611,10 @@ mod tests { ], }; - assert_eq!(identify_agent_in_job(&job), Some((Agent::Codex, "codex".to_string()))); + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Codex, "codex".to_string())) + ); } // ---- Workspace state rollup ---- @@ -651,10 +652,7 @@ mod tests { #[test] fn pi_busy_working_in_middle() { - assert_eq!( - detect_pi("line1\nWorking...\nline3"), - AgentState::Busy - ); + assert_eq!(detect_pi("line1\nWorking...\nline3"), AgentState::Busy); } #[test] @@ -664,10 +662,7 @@ mod tests { #[test] fn pi_idle_no_working_text() { - assert_eq!( - detect_pi("some output\n\n> ready"), - AgentState::Idle - ); + assert_eq!(detect_pi("some output\n\n> ready"), AgentState::Idle); } // ---- Claude Code ---- @@ -751,10 +746,7 @@ mod tests { #[test] fn codex_waiting_allow_command() { - assert_eq!( - detect_codex("allow command?\n[y/n]"), - AgentState::Waiting - ); + assert_eq!(detect_codex("allow command?\n[y/n]"), AgentState::Waiting); } #[test] @@ -829,18 +821,12 @@ mod tests { #[test] fn cursor_waiting_allow() { - assert_eq!( - detect_cursor("allow file edit (y)"), - AgentState::Waiting - ); + assert_eq!(detect_cursor("allow file edit (y)"), AgentState::Waiting); } #[test] fn cursor_busy_spinner() { - assert_eq!( - detect_cursor("⬡ Grepping.."), - AgentState::Busy - ); + assert_eq!(detect_cursor("⬡ Grepping.."), AgentState::Busy); } #[test] @@ -860,10 +846,7 @@ mod tests { #[test] fn cline_waiting_tool_use() { - assert_eq!( - detect_cline("let cline use this tool"), - AgentState::Waiting - ); + assert_eq!(detect_cline("let cline use this tool"), AgentState::Waiting); } #[test] @@ -1014,13 +997,15 @@ mod tests { #[test] fn droid_idle_prompt() { - let screen = "╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help"; + let screen = + "╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help"; assert_eq!(detect_droid(screen), AgentState::Idle); } #[test] fn droid_idle_after_response() { - let screen = "⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯"; + let screen = + "⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯"; assert_eq!(detect_droid(screen), AgentState::Idle); } @@ -1134,8 +1119,15 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(50)); let job = foreground_job(pid).expect("expected foreground job"); - assert!(job.processes.iter().any(|p| p.name == "sleep"), "expected sleep in {job:?}"); - assert_eq!(identify_agent_in_job(&job), None, "sleep should not map to an agent"); + assert!( + job.processes.iter().any(|p| p.name == "sleep"), + "expected sleep in {job:?}" + ); + assert_eq!( + identify_agent_in_job(&job), + None, + "sleep should not map to an agent" + ); // Clean up child.kill().ok(); @@ -1172,8 +1164,15 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(100)); let job = foreground_job(pid).expect("expected foreground job"); - assert!(job.processes.iter().any(|p| p.name == "sleep"), "expected sleep in {job:?}"); - assert_eq!(identify_agent_in_job(&job), None, "sleep should not map to an agent"); + assert!( + job.processes.iter().any(|p| p.name == "sleep"), + "expected sleep in {job:?}" + ); + assert_eq!( + identify_agent_in_job(&job), + None, + "sleep should not map to an agent" + ); child.kill().ok(); child.wait().ok(); @@ -1193,7 +1192,11 @@ mod tests { let fields: Vec<&str> = rest.split_whitespace().collect(); // We should have enough fields (at least 6 for tpgid) - assert!(fields.len() >= 6, "not enough fields in stat: {}", fields.len()); + assert!( + fields.len() >= 6, + "not enough fields in stat: {}", + fields.len() + ); // Field 0 should be a valid state char (S, R, D, etc.) let state = fields[0]; diff --git a/src/events.rs b/src/events.rs index e86850cb..58b16863 100644 --- a/src/events.rs +++ b/src/events.rs @@ -10,9 +10,7 @@ use crate::layout::PaneId; #[derive(Debug)] pub enum AppEvent { /// A pane's child process exited. - PaneDied { - pane_id: PaneId, - }, + PaneDied { pane_id: PaneId }, /// Agent state changed in a pane (detected by the PTY reader). StateChanged { pane_id: PaneId, @@ -20,7 +18,5 @@ pub enum AppEvent { state: AgentState, }, /// A new version was downloaded and installed. Restart to use it. - UpdateReady { - version: String, - }, + UpdateReady { version: String }, } diff --git a/src/input.rs b/src/input.rs index dbbf65df..f7c1f5c2 100644 --- a/src/input.rs +++ b/src/input.rs @@ -36,9 +36,17 @@ fn try_encode_csi_u(key: &KeyEvent) -> Option> { // understood. Even Ghostty sends these in legacy format with kitty mode on. // Only use CSI u for character keys and keys without legacy representations. match key.code { - KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right - | KeyCode::Home | KeyCode::End | KeyCode::PageUp | KeyCode::PageDown - | KeyCode::Insert | KeyCode::Delete | KeyCode::F(_) => { + KeyCode::Up + | KeyCode::Down + | KeyCode::Left + | KeyCode::Right + | KeyCode::Home + | KeyCode::End + | KeyCode::PageUp + | KeyCode::PageDown + | KeyCode::Insert + | KeyCode::Delete + | KeyCode::F(_) => { return None; // let legacy handle these } _ => {} @@ -262,10 +270,7 @@ mod tests { #[test] fn legacy_ctrl_shift_end() { - let key = KeyEvent::new( - KeyCode::End, - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ); + let key = KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT); assert_eq!(encode_key(key, false), b"\x1b[1;6F"); } @@ -330,10 +335,7 @@ mod tests { #[test] fn kitty_ctrl_shift_enter() { - let key = KeyEvent::new( - KeyCode::Enter, - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ); + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT); assert_eq!(encode_key(key, true), b"\x1b[13;6u"); } } diff --git a/src/layout.rs b/src/layout.rs index 23bdbb7d..c72456b9 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -193,12 +193,17 @@ impl TileLayout { .filter(|s| match target_dir { Direction::Horizontal => { // Border must be near the focused pane's left or right edge - let near_right = (s.pos as i32 - (focused_rect.x + focused_rect.width) as i32).unsigned_abs() <= 1; + let near_right = (s.pos as i32 - (focused_rect.x + focused_rect.width) as i32) + .unsigned_abs() + <= 1; let near_left = (s.pos as i32 - focused_rect.x as i32).unsigned_abs() <= 1; near_right || near_left } Direction::Vertical => { - let near_bottom = (s.pos as i32 - (focused_rect.y + focused_rect.height) as i32).unsigned_abs() <= 1; + let near_bottom = (s.pos as i32 + - (focused_rect.y + focused_rect.height) as i32) + .unsigned_abs() + <= 1; let near_top = (s.pos as i32 - focused_rect.y as i32).unsigned_abs() <= 1; near_bottom || near_top } @@ -212,9 +217,9 @@ impl TileLayout { (Direction::Horizontal, false) => { (focused_rect.x as i32 - s.pos as i32).unsigned_abs() } - (Direction::Vertical, true) => { - ((focused_rect.y + focused_rect.height) as i32 - s.pos as i32).unsigned_abs() - } + (Direction::Vertical, true) => ((focused_rect.y + focused_rect.height) as i32 + - s.pos as i32) + .unsigned_abs(), (Direction::Vertical, false) => { (focused_rect.y as i32 - s.pos as i32).unsigned_abs() } diff --git a/src/main.rs b/src/main.rs index 1e31b05a..5659a386 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,8 +49,8 @@ fn init_logging() { Err(_) => return, // can't open log file, proceed without logging }; - let filter = EnvFilter::try_from_env("HERDR_LOG") - .unwrap_or_else(|_| EnvFilter::new("herdr=info")); + let filter = + EnvFilter::try_from_env("HERDR_LOG").unwrap_or_else(|_| EnvFilter::new("herdr=info")); tracing_subscriber::fmt() .with_env_filter(filter) @@ -85,7 +85,13 @@ const DEFAULT_CONFIG: &str = r#"# herdr configuration # accent = "cyan" # Play sounds when agents change state in background workspaces -# sound = true +[ui.sound] +# enabled = true + +# Per-agent overrides: default | on | off +# By default, droid is muted. +# [ui.sound.agents] +# droid = "off" "#; fn main() -> io::Result<()> { @@ -134,7 +140,14 @@ fn main() -> io::Result<()> { } // Reject unknown flags - let known_flags = ["--no-session", "--version", "-V", "--default-config", "--help", "-h"]; + let known_flags = [ + "--no-session", + "--version", + "-V", + "--default-config", + "--help", + "-h", + ]; for arg in &args[1..] { if arg.starts_with('-') && !known_flags.contains(&arg.as_str()) { eprintln!("unknown option: {arg}"); diff --git a/src/pane.rs b/src/pane.rs index c9ab3649..5a7ae2bc 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1,5 +1,5 @@ -use std::io::{BufWriter, Read, Write}; use std::cell::Cell; +use std::io::{BufWriter, Read, Write}; use std::sync::{ atomic::{AtomicBool, AtomicU32, Ordering}, Arc, RwLock, @@ -91,7 +91,10 @@ impl PaneRuntime { let responses = PtyResponses::new(); let kitty_keyboard = responses.kitty_keyboard.clone(); let parser = Arc::new(RwLock::new(vt100::Parser::new_with_callbacks( - rows, cols, 10000, responses.clone(), + rows, + cols, + 10000, + responses.clone(), ))); let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); diff --git a/src/persist.rs b/src/persist.rs index 31a37576..3528dcaf 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -83,7 +83,9 @@ pub fn capture( fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot { let mut panes = HashMap::new(); for id in ws.panes.keys() { - let cwd = ws.runtimes.get(id) + let cwd = ws + .runtimes + .get(id) .and_then(|rt| rt.cwd()) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); panes.insert(id.raw(), PaneSnapshot { cwd }); @@ -153,7 +155,10 @@ fn restore_workspace( let mut panes = HashMap::new(); let mut runtimes = HashMap::new(); for id in &pane_ids { - let old_id = id_map.iter().find(|(_, new)| **new == *id).map(|(old, _)| old); + let old_id = id_map + .iter() + .find(|(_, new)| **new == *id) + .map(|(old, _)| old); let cwd = old_id .and_then(|old| snap.panes.get(old)) .map(|p| p.cwd.clone()) diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 524896d4..e2f368f8 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -79,7 +79,11 @@ fn foreground_pgid(pid: u32) -> Option { } let fg = info.e_tpgid; - if fg == 0 { None } else { Some(fg) } + if fg == 0 { + None + } else { + Some(fg) + } } /// Get the effective process name from `argv[0]` via `sysctl(KERN_PROCARGS2)`. @@ -122,9 +126,7 @@ fn process_argv0_name(pid: u32) -> Option { } // Return basename (argv[0] may be a full path like "/usr/bin/node") - let basename = Path::new(argv0) - .file_name()? - .to_str()?; + let basename = Path::new(argv0).file_name()?.to_str()?; // Strip leading dash (login shells show as "-zsh") let name = basename.strip_prefix('-').unwrap_or(basename); diff --git a/src/pty_callbacks.rs b/src/pty_callbacks.rs index 6d5971b0..113dbc89 100644 --- a/src/pty_callbacks.rs +++ b/src/pty_callbacks.rs @@ -135,7 +135,8 @@ impl vt100::Callbacks for PtyResponses { break; } } - self.kitty_keyboard.store(!stack.is_empty(), Ordering::Relaxed); + self.kitty_keyboard + .store(!stack.is_empty(), Ordering::Relaxed); } // === Terminal Identification === @@ -150,11 +151,7 @@ impl vt100::Callbacks for PtyResponses { } } - fn unhandled_osc( - &mut self, - _screen: &mut vt100::Screen, - params: &[&[u8]], - ) { + fn unhandled_osc(&mut self, _screen: &mut vt100::Screen, params: &[&[u8]]) { let Some(cmd) = params.first() else { return }; match *cmd { @@ -237,7 +234,7 @@ mod tests { let r = PtyResponses::new(); let mut p = make_parser(r.clone()); p.process(b"\x1b[3;7H"); // move cursor - p.process(b"\x1b[?6n"); // extended CPR + p.process(b"\x1b[?6n"); // extended CPR assert_eq!(r.take(), b"\x1b[?3;7R"); } diff --git a/src/selection.rs b/src/selection.rs index 7b020575..a8f24220 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -248,6 +248,4 @@ mod tests { assert_eq!(row, 0); assert_eq!(col, 0); } - - } diff --git a/src/sound.rs b/src/sound.rs index f4123e10..e184d02c 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -40,9 +40,10 @@ fn play_bytes(data: &[u8]) -> Result<(), String> { Command::new("afplay").arg(&tmp).output() } else { // Try paplay (PulseAudio) first, fall back to aplay (ALSA) - Command::new("paplay").arg(&tmp).output().or_else(|_| { - Command::new("aplay").arg(&tmp).output() - }) + Command::new("paplay") + .arg(&tmp) + .output() + .or_else(|_| Command::new("aplay").arg(&tmp).output()) }; let _ = std::fs::remove_file(&tmp); diff --git a/src/ui.rs b/src/ui.rs index 242853ea..15e5af36 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -153,7 +153,12 @@ fn compute_sidebar_width(app: &AppState) -> u16 { fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { let is_navigating = matches!( app.mode, - Mode::Navigate | Mode::CreateSession | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu + Mode::Navigate + | Mode::CreateSession + | Mode::RenameSession + | Mode::Resize + | Mode::ConfirmClose + | Mode::ContextMenu ); // Thin vertical separator line on the right edge @@ -205,7 +210,14 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { let line = Line::from(vec![ Span::styled(&num_label, dim_style), - Span::styled(" ", if is_selected { row_style } else { Style::default() }), + Span::styled( + " ", + if is_selected { + row_style + } else { + Style::default() + }, + ), Span::styled( icon, if is_selected { @@ -226,7 +238,12 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { let is_navigating = matches!( app.mode, - Mode::Navigate | Mode::CreateSession | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu + Mode::Navigate + | Mode::CreateSession + | Mode::RenameSession + | Mode::Resize + | Mode::ConfirmClose + | Mode::ContextMenu ); let highlight_style = if is_navigating { @@ -264,9 +281,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { Mode::ConfirmClose => " CLOSE?".to_string(), }; let title_style = if is_navigating { - Style::default() - .fg(app.accent) - .add_modifier(Modifier::BOLD) + Style::default().fg(app.accent).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; @@ -394,10 +409,7 @@ fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool) { let x = area.x + content_w / 2; let toggle_area = Rect::new(x, bottom_y, 1, 1); frame.render_widget( - Paragraph::new(Span::styled( - icon, - Style::default().fg(Color::DarkGray), - )), + Paragraph::new(Span::styled(icon, Style::default().fg(Color::DarkGray))), toggle_area, ); } @@ -536,9 +548,7 @@ fn render_empty(frame: &mut Frame, area: Rect, accent: Color) { Span::styled(" Press ", Style::default().fg(Color::DarkGray)), Span::styled( "n", - Style::default() - .fg(accent) - .add_modifier(Modifier::BOLD), + Style::default().fg(accent).add_modifier(Modifier::BOLD), ), Span::styled(" to create one", Style::default().fg(Color::DarkGray)), ]), @@ -555,9 +565,7 @@ fn render_empty(frame: &mut Frame, area: Rect, accent: Color) { /// Floating overlay for navigate mode — appears at bottom of terminal area. fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.accent) - .add_modifier(Modifier::BOLD); + let key = Style::default().fg(app.accent).add_modifier(Modifier::BOLD); let dim = Style::default().fg(Color::DarkGray); let label = Style::default().fg(Color::White); @@ -653,9 +661,7 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { /// Floating overlay for resize mode. fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.accent) - .add_modifier(Modifier::BOLD); + let key = Style::default().fg(app.accent).add_modifier(Modifier::BOLD); let dim = Style::default().fg(Color::DarkGray); let mode_style = Style::default() @@ -721,20 +727,19 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); - let key = Style::default() - .fg(app.accent) - .add_modifier(Modifier::BOLD); - let warn = Style::default() - .fg(Color::Red) - .add_modifier(Modifier::BOLD); + let key = Style::default().fg(app.accent).add_modifier(Modifier::BOLD); + let warn = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); let dim = Style::default().fg(Color::DarkGray); - let title_line = Line::from(vec![ - Span::styled(" Close workspace?", warn), - ]); + let title_line = Line::from(vec![Span::styled(" Close workspace?", warn)]); let detail_line = Line::from(vec![ - Span::styled(format!(" {ws_name}"), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled( + format!(" {ws_name}"), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), Span::styled(format!(" — {pane_text}"), dim), ]); @@ -806,12 +811,13 @@ fn render_context_menu(app: &AppState, frame: &mut Frame) { if i as u16 >= inner.height { break; } - let style = if i == menu.selected { highlight } else { normal }; + let style = if i == menu.selected { + highlight + } else { + normal + }; let row = Rect::new(inner.x, inner.y + i as u16, inner.width, 1); - frame.render_widget( - Paragraph::new(format!(" {item}")).style(style), - row, - ); + frame.render_widget(Paragraph::new(format!(" {item}")).style(style), row); } } @@ -850,7 +856,7 @@ fn state_icon_style(state: AgentState, seen: bool) -> (&'static str, Style) { match (state, seen) { (AgentState::Waiting, _) => ("●", Style::default().fg(Color::Red)), (AgentState::Busy, _) => ("●", Style::default().fg(Color::Yellow)), - (AgentState::Idle, false) => ("●", Style::default().fg(Color::Blue)), // Done + (AgentState::Idle, false) => ("●", Style::default().fg(Color::Blue)), // Done (AgentState::Idle, true) => ("○", Style::default().fg(Color::Green)), (AgentState::Unknown, _) => ("·", Style::default().fg(Color::DarkGray)), } diff --git a/src/update.rs b/src/update.rs index f025f847..e3fb0d77 100644 --- a/src/update.rs +++ b/src/update.rs @@ -84,9 +84,12 @@ fn check_latest() -> Result, String> { let output = Command::new("curl") .args([ "-sfL", - "--max-time", "10", - "-H", "Accept: application/vnd.github+json", - "-H", "User-Agent: herdr-updater", + "--max-time", + "10", + "-H", + "Accept: application/vnd.github+json", + "-H", + "User-Agent: herdr-updater", &url, ]) .output() @@ -129,12 +132,9 @@ fn check_latest() -> Result, String> { /// Download and install a release. Returns the installed version. fn download_and_install(release: &ReleaseInfo) -> Result<(), String> { - let current_exe = env::current_exe() - .map_err(|e| format!("can't find current binary: {e}"))?; + let current_exe = env::current_exe().map_err(|e| format!("can't find current binary: {e}"))?; - let parent = current_exe - .parent() - .ok_or("can't find binary directory")?; + let parent = current_exe.parent().ok_or("can't find binary directory")?; // Check write permissions early let test_path = parent.join(".herdr-write-test"); @@ -149,10 +149,7 @@ fn download_and_install(release: &ReleaseInfo) -> Result<(), String> { let _ = fs::remove_file(&test_path); // Unique temp file (avoids races with concurrent instances) - let tmp_path = parent.join(format!( - ".herdr-update-{}.tmp", - std::process::id() - )); + let tmp_path = parent.join(format!(".herdr-update-{}.tmp", std::process::id())); // Download the exact asset URL (pinned to the release we checked) let status = Command::new("curl") @@ -232,7 +229,10 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender) { return; } - tracing::info!("auto-update: v{} installed, restart to use", release.version); + tracing::info!( + "auto-update: v{} installed, restart to use", + release.version + ); // Notify the TUI — blocking_send is safe from a std::thread let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { @@ -276,7 +276,11 @@ mod tests { fn parse_version_basic() { assert_eq!( Version::parse("1.2.3"), - Some(Version { major: 1, minor: 2, patch: 3 }) + Some(Version { + major: 1, + minor: 2, + patch: 3 + }) ); } @@ -284,7 +288,11 @@ mod tests { fn parse_version_with_v_prefix() { assert_eq!( Version::parse("v0.1.0"), - Some(Version { major: 0, minor: 1, patch: 0 }) + Some(Version { + major: 0, + minor: 1, + patch: 0 + }) ); } @@ -310,7 +318,11 @@ mod tests { #[test] fn version_display() { - let v = Version { major: 0, minor: 1, patch: 0 }; + let v = Version { + major: 0, + minor: 1, + patch: 0, + }; assert_eq!(v.to_string(), "0.1.0"); } diff --git a/src/workspace.rs b/src/workspace.rs index 2b8a2902..a4cdfcc4 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -58,9 +58,8 @@ impl Workspace { cwd: Option, ) -> std::io::Result { let new_id = self.layout.split_focused(direction); - let actual_cwd = cwd.unwrap_or_else(|| { - std::env::current_dir().unwrap_or_else(|_| "/".into()) - }); + let actual_cwd = + cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let runtime = PaneRuntime::spawn(new_id, rows, cols, actual_cwd, self.events.clone())?; self.panes.insert(new_id, PaneState::new()); self.runtimes.insert(new_id, runtime);