diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9ee64e..48623f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Added +- Config can now be reloaded in the running app/server from the global menu or with `herdr server reload-config`, applying safe live settings without restarting the persistent server. + +### Fixed +- Persistent server startup now surfaces config diagnostics in attached clients instead of silently hiding parse or validation errors. + ## [0.5.1] - 2026-04-25 ### Added diff --git a/CONFIGURATION.md b/CONFIGURATION.md index d943dd6b..a80e6bd0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -14,6 +14,35 @@ herdr --default-config if a config value is invalid, or two navigate actions use the same keybinding, herdr falls back to a safe default and shows a startup warning in the UI. +## live reload + +After editing `config.toml`, reload the running app without restarting the persistent server: + +```bash +herdr server reload-config +``` + +You can also use the global menu inside herdr and choose `reload config`. + +Reload is server-owned. In persistent mode the CLI sends a request to the running server, and the server reads, parses, validates, and applies `config.toml`. + +Reloadable now: +- keybindings and prefix +- theme, custom theme colors, and legacy `ui.accent` +- `ui.confirm_close` +- `ui.toast.delivery` +- server-side `ui.sound` policy +- `advanced.scrollback_limit_bytes` for panes created after reload +- `ui.sidebar_width` as the default width + +Startup-only or special-case: +- `onboarding` does not reopen onboarding during reload +- `advanced.allow_nested` is checked before launch and needs a restart +- existing pane scrollback buffers are not resized during reload +- already-attached thin clients may need to reconnect before client-local sound file/path changes are picked up + +If the TOML cannot be read or parsed, reload applies nothing and keeps the current running state. If keybindings are invalid, herdr keeps the current keybindings while applying other valid reloadable settings where possible. + ## onboarding ```toml @@ -54,6 +83,7 @@ prefix = "ctrl+b" new_workspace = "n" rename_workspace = "shift+n" close_workspace = "shift+d" +reload_config = "" # optional, unset by default new_tab = "c" split_vertical = "v" split_horizontal = "-" @@ -80,6 +110,7 @@ focus_pane_right = "alt+l" | `rename_workspace` | `shift+n` | rename selected workspace | | `close_workspace` | `shift+d` | close selected workspace | | `detach` | unset | optional explicit detach shortcut in the persistent session | +| `reload_config` | unset | reload `config.toml` in the running app/server | | `previous_workspace` | unset | switch to the previous workspace directly from terminal mode | | `next_workspace` | unset | switch to the next workspace directly from terminal mode | | `new_tab` | `c` | create a new tab | diff --git a/src/api/mod.rs b/src/api/mod.rs index cb4f120a..33a3cd44 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -30,7 +30,8 @@ const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) fn request_changes_ui(request: &Request) -> bool { matches!( &request.method, - Method::WorkspaceCreate(_) + Method::ServerReloadConfig(_) + | Method::WorkspaceCreate(_) | Method::WorkspaceFocus(_) | Method::WorkspaceRename(_) | Method::WorkspaceClose(_) @@ -317,6 +318,7 @@ fn api_method_name(method: &Method) -> &'static str { match method { Method::Ping(_) => "ping", Method::ServerStop(_) => "server.stop", + Method::ServerReloadConfig(_) => "server.reload_config", Method::WorkspaceCreate(_) => "workspace.create", Method::WorkspaceList(_) => "workspace.list", Method::WorkspaceGet(_) => "workspace.get", diff --git a/src/api/schema.rs b/src/api/schema.rs index 6cb81c12..f7665fd9 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -14,6 +14,8 @@ pub enum Method { Ping(PingParams), #[serde(rename = "server.stop")] ServerStop(EmptyParams), + #[serde(rename = "server.reload_config")] + ServerReloadConfig(EmptyParams), #[serde(rename = "workspace.create")] WorkspaceCreate(WorkspaceCreateParams), #[serde(rename = "workspace.list")] @@ -475,6 +477,10 @@ pub enum ResponseResult { target: IntegrationTarget, details: IntegrationUninstallResult, }, + ConfigReload { + status: crate::config::ConfigReloadStatus, + diagnostics: Vec, + }, Ok {}, } @@ -765,6 +771,19 @@ mod tests { assert_eq!(restored, request); } + #[test] + fn request_round_trips_for_server_reload_config() { + let request = Request { + id: "req_reload".into(), + method: Method::ServerReloadConfig(EmptyParams::default()), + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "server.reload_config"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); + } + #[test] fn unknown_method_is_rejected() { let json = r#"{"id":"req_1","method":"nope","params":{}}"#; diff --git a/src/app/actions.rs b/src/app/actions.rs index b1bdfc63..210b6ccf 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -587,7 +587,7 @@ impl AppState { pane.seen = false; } - if self.sound.allows(change.known_agent) { + if self.local_sound_playback && self.sound.allows(change.known_agent) { if let Some(sound) = notification_sound_for_state_change( is_active_tab, change.previous_state, diff --git a/src/app/api.rs b/src/app/api.rs index 364097dc..d7893d6d 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -282,6 +282,16 @@ impl App { result: ResponseResult::Ok {}, } } + Method::ServerReloadConfig(_) => { + let report = self.reload_config(); + SuccessResponse { + id: request.id, + result: ResponseResult::ConfigReload { + status: report.status, + diagnostics: report.diagnostics, + }, + } + } Method::WorkspaceList(_) => SuccessResponse { id: request.id, result: ResponseResult::WorkspaceList { diff --git a/src/app/config_io.rs b/src/app/config_io.rs index 6770d61e..6c51ad9f 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -1,7 +1,7 @@ use super::App; impl App { - pub(super) fn update_config_file(&mut self, error_context: &str, update: F) + pub(super) fn update_config_file(&mut self, error_context: &str, update: F) -> bool where F: FnOnce(&str) -> String, { @@ -13,7 +13,7 @@ impl App { Some(format!("failed to save {error_context}: {err}")); self.config_diagnostic_deadline = Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); - return; + return false; } } @@ -24,7 +24,10 @@ impl App { self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}")); self.config_diagnostic_deadline = Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); + return false; } + + true } pub(super) fn mark_onboarding_complete(&mut self) { @@ -34,15 +37,19 @@ impl App { } pub(super) fn save_theme(&mut self, name: &str) { - self.update_config_file("theme", |content| { + if self.update_config_file("theme", |content| { crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\"")) - }); + }) { + self.apply_config_from_disk(false); + } } pub(super) fn save_sound(&mut self, enabled: bool) { - self.update_config_file("sound setting", |content| { + if self.update_config_file("sound setting", |content| { crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled) - }); + }) { + self.apply_config_from_disk(false); + } } pub(super) fn save_toast_delivery(&mut self, delivery: crate::config::ToastDelivery) { @@ -51,10 +58,12 @@ impl App { crate::config::ToastDelivery::Herdr => "\"herdr\"", crate::config::ToastDelivery::Terminal => "\"terminal\"", }; - self.update_config_file("toast setting", |content| { + if self.update_config_file("toast setting", |content| { let content = crate::config::upsert_section_value(content, "ui.toast", "delivery", value); crate::config::remove_section_key(&content, "ui.toast", "enabled") - }); + }) { + self.apply_config_from_disk(false); + } } } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index aa25ea1e..998bb69b 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -69,7 +69,7 @@ pub(crate) enum GlobalMenuAction { Quit, WhatsNew, Keybinds, - ReloadKeybinds, + ReloadConfig, Settings, } @@ -77,7 +77,7 @@ pub(super) fn global_menu_actions(state: &AppState) -> Vec { let mut actions = vec![ GlobalMenuAction::Settings, GlobalMenuAction::Keybinds, - GlobalMenuAction::ReloadKeybinds, + GlobalMenuAction::ReloadConfig, ]; if state.update_available.is_some() || state.latest_release_notes_available { actions.push(GlobalMenuAction::WhatsNew); @@ -126,8 +126,8 @@ pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuA } GlobalMenuAction::WhatsNew => open_update_release_notes(state), GlobalMenuAction::Keybinds => open_keybind_help(state), - GlobalMenuAction::ReloadKeybinds => { - state.request_reload_keybinds = true; + GlobalMenuAction::ReloadConfig => { + state.request_reload_config = true; leave_modal(state); } GlobalMenuAction::Settings => super::settings::open_settings(state), diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 23b93379..b961c6d3 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -350,6 +350,7 @@ pub(crate) enum NavigateAction { Fullscreen, EnterResizeMode, ToggleSidebar, + ReloadConfig, Detach, } @@ -421,6 +422,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option { + state.request_reload_config = true; + leave_navigate_mode(state); + } NavigateAction::Detach => { state.detach_requested = true; leave_navigate_mode(state); @@ -582,6 +593,21 @@ mod tests { assert_eq!(state.mode, Mode::Resize); } + #[test] + fn custom_reload_config_key_requests_reload_and_exits_navigate() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.reload_config = Some((KeyCode::Char('g'), KeyModifiers::empty())); + state.keybinds.reload_config_label = Some("g".into()); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert!(state.request_reload_config); + assert_eq!(state.mode, Mode::Terminal); + } + #[test] fn movement_action_stays_in_navigate_mode() { let mut state = state_with_workspaces(&["a", "b"]); diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 8bf06dc6..2c92bc63 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -125,7 +125,6 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti } KeyCode::Enter | KeyCode::Char(' ') => { let enabled = state.settings.list.selected == 0; - state.sound.enabled = enabled; return Some(SettingsAction::SaveSound(enabled)); } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { @@ -146,8 +145,6 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(3), KeyCode::Enter | KeyCode::Char(' ') => { let delivery = toast_delivery_for_index(state.settings.list.selected); - state.toast_config.delivery = delivery; - state.toast = None; return Some(SettingsAction::SaveToastDelivery(delivery)); } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { @@ -271,13 +268,10 @@ impl AppState { } SettingsSection::Sound => { let enabled = idx == 0; - self.sound.enabled = enabled; Some(SettingsAction::SaveSound(enabled)) } SettingsSection::Toast => { let delivery = toast_delivery_for_index(idx); - self.toast_config.delivery = delivery; - self.toast = None; Some(SettingsAction::SaveToastDelivery(delivery)) } }; @@ -362,7 +356,7 @@ mod tests { ); assert_eq!(action, Some(SettingsAction::SaveSound(true))); - assert!(state.sound.enabled); + assert!(!state.sound.enabled); assert_eq!(state.mode, Mode::Settings); } diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 9de8868b..0abfd446 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -182,7 +182,7 @@ impl AppState { } pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { - let mut labels = vec!["settings", "keybinds", "reload keybinds"]; + let mut labels = vec!["settings", "keybinds", "reload config"]; if self.update_available.is_some() { labels.push("update ready"); } else if self.latest_release_notes_available { @@ -530,7 +530,7 @@ mod tests { } #[test] - fn clicking_reload_keybinds_menu_item_requests_reload() { + fn clicking_reload_config_menu_item_requests_reload() { let mut app = app_for_mouse_test(); let launcher = app.state.global_launcher_rect(); app.handle_mouse(mouse( @@ -546,7 +546,7 @@ mod tests { menu.y + 3, )); - assert!(app.state.request_reload_keybinds); + assert!(app.state.request_reload_config); assert_eq!(app.state.mode, Mode::Navigate); } @@ -568,7 +568,7 @@ mod tests { vec![ "settings", "keybinds", - "reload keybinds", + "reload config", "update ready", "quit" ] @@ -590,7 +590,7 @@ mod tests { assert_eq!( app.state.global_menu_labels(), - vec!["settings", "keybinds", "reload keybinds", "detach"] + vec!["settings", "keybinds", "reload config", "detach"] ); let menu = app.state.global_menu_rect(); @@ -615,7 +615,7 @@ mod tests { vec![ "settings", "keybinds", - "reload keybinds", + "reload config", "what's new", "quit" ] diff --git a/src/app/mod.rs b/src/app/mod.rs index 14c50f7f..c4806a77 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -247,7 +247,7 @@ impl App { detach_requested: false, request_new_workspace: false, request_new_tab: false, - request_reload_keybinds: false, + request_reload_config: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, @@ -299,6 +299,7 @@ impl App { pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), + local_sound_playback: true, toast_config: config.ui.toast.clone(), keybinds: config.keybinds(), spinner_tick: 0, @@ -415,9 +416,9 @@ impl App { needs_render = true; } - if self.state.request_reload_keybinds { - self.state.request_reload_keybinds = false; - self.reload_keybinds(); + if self.state.request_reload_config { + self.state.request_reload_config = false; + self.reload_config(); needs_render = true; } @@ -531,32 +532,105 @@ impl App { crate::app::input::open_settings(&mut self.state); } - pub(crate) fn reload_keybinds(&mut self) { + pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport { + self.apply_config_from_disk(true) + } + + pub(crate) fn apply_config_from_disk( + &mut self, + notify_success: bool, + ) -> crate::config::ConfigReloadReport { let previous_toast = self.state.toast.clone(); - match crate::config::load_live_keybinds() { + let report = match crate::config::load_live_config() { + Ok(loaded) => self.apply_live_config(&loaded.config, notify_success), + Err(diagnostics) => { + self.state.toast = None; + self.state.config_diagnostic = + crate::config::config_diagnostic_summary(&diagnostics); + self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8)); + crate::config::ConfigReloadReport { + status: crate::config::ConfigReloadStatus::Failed, + diagnostics, + } + } + }; + self.sync_toast_deadline(previous_toast); + report + } + + fn apply_live_config( + &mut self, + config: &crate::config::Config, + notify_success: bool, + ) -> crate::config::ConfigReloadReport { + let mut diagnostics = Vec::new(); + + match config.live_keybinds() { Ok(live) => { self.state.prefix_code = live.prefix.0; self.state.prefix_mods = live.prefix.1; self.state.keybinds = live.keybinds; - self.state.config_diagnostic = None; - self.config_diagnostic_deadline = None; - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::UpdateInstalled, - title: "reloaded keybinds".to_string(), - context: "using config.toml".to_string(), - }); } - Err(diagnostics) => { - let mut message = diagnostics.join("; "); + Err(keybind_diagnostics) => { + let mut message = keybind_diagnostics.join("; "); if !message.contains("keeping current keybinds") { message.push_str("; keeping current keybinds"); } - self.state.toast = None; - self.state.config_diagnostic = Some(message); - self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8)); + diagnostics.push(message); } } - self.sync_toast_deadline(previous_toast); + + diagnostics.extend(config.ui.sound.diagnostics()); + + let previous_default_sidebar_width = self.state.default_sidebar_width; + self.state.default_sidebar_width = config.ui.sidebar_width; + if self.state.sidebar_width == previous_default_sidebar_width { + self.state.sidebar_width = config.ui.sidebar_width; + } + self.state.confirm_close = config.ui.confirm_close; + self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes; + self.state.accent = crate::config::parse_color(&config.ui.accent); + self.state.sound = config.ui.sound.clone(); + self.state.toast_config = config.ui.toast.clone(); + self.state.palette = resolve_palette(config); + self.state.theme_name = config + .theme + .name + .clone() + .unwrap_or_else(|| "catppuccin".to_string()); + + let status = if diagnostics.is_empty() { + crate::config::ConfigReloadStatus::Applied + } else { + crate::config::ConfigReloadStatus::Partial + }; + + if diagnostics.is_empty() { + self.state.config_diagnostic = None; + self.config_diagnostic_deadline = None; + if notify_success { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::UpdateInstalled, + title: "reloaded config".to_string(), + context: "using config.toml".to_string(), + }); + } + } else { + self.state.config_diagnostic = crate::config::config_diagnostic_summary(&diagnostics); + self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8)); + if notify_success { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::UpdateInstalled, + title: "reloaded config".to_string(), + context: "with warnings".to_string(), + }); + } + } + + crate::config::ConfigReloadReport { + status, + diagnostics, + } } } @@ -794,30 +868,35 @@ mod tests { } #[test] - fn reload_keybinds_updates_live_state() { + fn reload_config_updates_live_state() { let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-keybinds-success"); + let path = temp_config_path("reload-config-success"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n", + "[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - app.reload_keybinds(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); assert_eq!(app.state.prefix_code, KeyCode::Char('a')); assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); assert_eq!( app.state.keybinds.new_workspace, (KeyCode::Char('g'), KeyModifiers::empty()) ); + assert_eq!( + app.state.toast_config.delivery, + crate::config::ToastDelivery::Herdr + ); assert!(app.state.config_diagnostic.is_none()); let toast = app.state.toast.as_ref().unwrap(); assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled); - assert_eq!(toast.title, "reloaded keybinds"); + assert_eq!(toast.title, "reloaded config"); assert_eq!(toast.context, "using config.toml"); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -825,23 +904,32 @@ mod tests { } #[test] - fn reload_keybinds_keeps_current_state_on_invalid_binding() { + fn reload_config_keeps_current_keybinds_on_invalid_binding_but_applies_other_sections() { let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-keybinds-invalid"); + let path = temp_config_path("reload-config-invalid-keybind"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "[keys]\nnew_workspace = \"wat\"\n").unwrap(); + std::fs::write( + &path, + "[keys]\nnew_workspace = \"wat\"\n[ui.toast]\ndelivery = \"terminal\"\n", + ) + .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); let original_prefix = (app.state.prefix_code, app.state.prefix_mods); let original_keybinds = app.state.keybinds.new_workspace; - app.reload_keybinds(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); assert_eq!( (app.state.prefix_code, app.state.prefix_mods), original_prefix ); assert_eq!(app.state.keybinds.new_workspace, original_keybinds); + assert_eq!( + app.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ); assert!(app .state .config_diagnostic @@ -850,6 +938,67 @@ mod tests { message.contains("keys.new_workspace") && message.contains("keeping current keybinds") })); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn settings_save_toast_delivery_persists_then_applies_live_config() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("settings-save-toast-delivery"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "onboarding = false\n").unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + assert_eq!( + app.state.toast_config.delivery, + crate::config::ToastDelivery::Off + ); + + app.save_toast_delivery(crate::config::ToastDelivery::Terminal); + + assert_eq!( + app.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("delivery = \"terminal\"")); + assert!(app.state.config_diagnostic.is_none()); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn reload_config_keeps_current_state_on_invalid_toml() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-invalid-toml"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[keys\nnew_workspace = \"g\"\n").unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + let original_prefix = (app.state.prefix_code, app.state.prefix_mods); + let original_keybinds = app.state.keybinds.new_workspace; + let original_toast_delivery = app.state.toast_config.delivery; + let report = app.reload_config(); + + assert_eq!(report.status, crate::config::ConfigReloadStatus::Failed); + assert_eq!( + (app.state.prefix_code, app.state.prefix_mods), + original_prefix + ); + assert_eq!(app.state.keybinds.new_workspace, original_keybinds); + assert_eq!(app.state.toast_config.delivery, original_toast_delivery); + assert!(app + .state + .config_diagnostic + .as_deref() + .is_some_and(|message| { + message.contains("config parse error") && message.contains("keeping current config") + })); assert!(app.state.toast.is_none()); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); diff --git a/src/app/state.rs b/src/app/state.rs index e54b431a..a51a72e7 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -593,7 +593,7 @@ pub struct AppState { pub detach_requested: bool, pub request_new_workspace: bool, pub request_new_tab: bool, - pub request_reload_keybinds: bool, + pub request_reload_config: bool, /// Set when UI interaction requested a clipboard write that must be /// handled by the outer App/event loop instead of directly from AppState. pub request_clipboard_write: Option>, @@ -636,6 +636,7 @@ pub struct AppState { #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth pub accent: Color, pub sound: SoundConfig, + pub local_sound_playback: bool, pub toast_config: ToastConfig, pub keybinds: Keybinds, /// Frame counter for spinner animations (wraps around). @@ -717,7 +718,7 @@ impl AppState { detach_requested: false, request_new_workspace: false, request_new_tab: false, - request_reload_keybinds: false, + request_reload_config: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, @@ -767,6 +768,7 @@ impl AppState { enabled: false, ..SoundConfig::default() }, + local_sound_playback: false, toast_config: ToastConfig::default(), keybinds: Keybinds { new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()), @@ -777,6 +779,8 @@ impl AppState { close_workspace_label: "shift+d".into(), detach: None, detach_label: None, + reload_config: None, + reload_config_label: None, previous_workspace: None, previous_workspace_label: None, next_workspace: None, diff --git a/src/cli.rs b/src/cli.rs index feb24c70..8421863a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -49,6 +49,7 @@ fn run_server_command(args: &[String]) -> std::io::Result> { match subcommand { "stop" => server_stop(&args[1..]).map(Some), + "reload-config" => server_reload_config(&args[1..]).map(Some), "help" | "--help" | "-h" => { print_server_help(); Ok(Some(0)) @@ -163,6 +164,18 @@ fn server_stop(args: &[String]) -> std::io::Result { send_ok_request(Method::ServerStop(EmptyParams::default())) } +fn server_reload_config(args: &[String]) -> std::io::Result { + if !args.is_empty() { + eprintln!("usage: herdr server reload-config"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:server:reload-config".into(), + method: Method::ServerReloadConfig(EmptyParams::default()), + })?) +} + fn workspace_list(args: &[String]) -> std::io::Result { if !args.is_empty() { eprintln!("usage: herdr workspace list"); @@ -1041,6 +1054,7 @@ fn print_server_help() { eprintln!("herdr server commands:"); eprintln!(" herdr server run as headless server"); eprintln!(" herdr server stop stop the running server via the api socket"); + eprintln!(" herdr server reload-config reload config.toml in the running server"); } fn print_workspace_help() { diff --git a/src/config.rs b/src/config.rs index 71b20f52..e96191bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,15 +8,15 @@ mod theme; pub use self::{ io::{ - config_dir, config_path, load_live_keybinds, remove_section_key, upsert_section_bool, - upsert_section_value, + config_diagnostic_summary, config_dir, config_path, load_live_config, remove_section_key, + upsert_section_bool, upsert_section_value, }, keybinds::{ format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, Keybinds, LiveKeybindConfig, }, - model::{Config, ToastConfig, ToastDelivery}, - sound::{AgentSoundSetting, SoundConfig}, + model::{Config, ConfigReloadReport, ConfigReloadStatus, ToastConfig, ToastDelivery}, + sound::SoundConfig, theme::{parse_color, CustomThemeColors, ThemeConfig}, }; diff --git a/src/config/io.rs b/src/config/io.rs index 1a3c1920..2774fc93 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use tracing::warn; -use super::{model::LoadedConfig, Config, LiveKeybindConfig, CONFIG_PATH_ENV_VAR}; +use super::{model::LoadedConfig, Config, CONFIG_PATH_ENV_VAR}; pub fn app_dir_name() -> &'static str { if cfg!(debug_assertions) { @@ -77,23 +77,37 @@ pub fn config_path() -> PathBuf { config_dir().join("config.toml") } -pub fn load_live_keybinds() -> Result> { +pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option { + if diagnostics.is_empty() { + None + } else if diagnostics.len() == 1 { + Some(diagnostics[0].clone()) + } else { + Some(format!( + "{} (and {} more)", + diagnostics[0], + diagnostics.len() - 1 + )) + } +} + +pub fn load_live_config() -> Result> { let path = config_path(); if !path.exists() { - return Config::default().live_keybinds(); + return Ok(LoadedConfig { + config: Config::default(), + diagnostics: Vec::new(), + }); } - 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::(&content).map_err(|err| { - vec![format!( - "config parse error: {err}; keeping current keybinds" - )] - })?; - config.live_keybinds() + let content = std::fs::read_to_string(&path) + .map_err(|err| vec![format!("config read error: {err}; keeping current config")])?; + let config = toml::from_str::(&content) + .map_err(|err| vec![format!("config parse error: {err}; keeping current config")])?; + Ok(LoadedConfig { + config, + diagnostics: Vec::new(), + }) } pub(crate) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 07c682ba..4a54dd74 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -65,6 +65,8 @@ pub struct Keybinds { pub close_workspace_label: String, pub detach: Option<(KeyCode, KeyModifiers)>, pub detach_label: Option, + pub reload_config: Option<(KeyCode, KeyModifiers)>, + pub reload_config_label: Option, pub previous_workspace: Option<(KeyCode, KeyModifiers)>, pub previous_workspace_label: Option, pub next_workspace: Option<(KeyCode, KeyModifiers)>, @@ -335,6 +337,12 @@ impl Config { &self.keys.detach, &mut diagnostics, ), + optional_binding( + BindingScope::Navigate, + "keys.reload_config", + &self.keys.reload_config, + &mut diagnostics, + ), optional_binding( BindingScope::Navigate, "keys.previous_workspace", @@ -578,28 +586,30 @@ impl Config { 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(), + reload_config: optional_bindings[1].value, + reload_config_label: optional_bindings[1].label.clone(), + previous_workspace: optional_bindings[2].value, + previous_workspace_label: optional_bindings[2].label.clone(), + next_workspace: optional_bindings[3].value, + next_workspace_label: optional_bindings[3].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(), + rename_tab: optional_bindings[4].value, + rename_tab_label: optional_bindings[4].label.clone(), + previous_tab: optional_bindings[5].value, + previous_tab_label: optional_bindings[5].label.clone(), + next_tab: optional_bindings[6].value, + next_tab_label: optional_bindings[6].label.clone(), + close_tab: optional_bindings[7].value, + close_tab_label: optional_bindings[7].label.clone(), + focus_pane_left: optional_bindings[8].value, + focus_pane_left_label: optional_bindings[8].label.clone(), + focus_pane_down: optional_bindings[9].value, + focus_pane_down_label: optional_bindings[9].label.clone(), + focus_pane_up: optional_bindings[10].value, + focus_pane_up_label: optional_bindings[10].label.clone(), + focus_pane_right: optional_bindings[11].value, + focus_pane_right_label: optional_bindings[11].label.clone(), split_vertical: bindings[4].value, split_vertical_label: bindings[4].label.clone(), split_horizontal: bindings[5].value, diff --git a/src/config/model.rs b/src/config/model.rs index e2c39f25..32d60493 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -16,6 +16,20 @@ pub struct ToastConfig { pub delivery: ToastDelivery, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfigReloadStatus { + Applied, + Partial, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ConfigReloadReport { + pub status: ConfigReloadStatus, + pub diagnostics: Vec, +} + #[derive(Debug, Default, Deserialize)] #[serde(default)] pub struct Config { @@ -45,6 +59,8 @@ pub struct KeysConfig { pub close_workspace: String, /// Optional explicit detach shortcut in server/client mode. Unset by default. pub detach: String, + /// Reload config.toml in the running app/server. Unset by default. + pub reload_config: String, /// Select the previous workspace. Unset by default. pub previous_workspace: String, /// Select the next workspace. Unset by default. @@ -116,6 +132,7 @@ impl Default for KeysConfig { rename_workspace: "shift+n".into(), close_workspace: "shift+d".into(), detach: "".into(), + reload_config: "".into(), previous_workspace: "".into(), next_workspace: "".into(), new_tab: "c".into(), diff --git a/src/main.rs b/src/main.rs index 982e787d..9b66198f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,6 +83,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # previous_workspace = "" # optional, unset by default # next_workspace = "" # optional, unset by default # detach = "" # optional explicit detach shortcut in server/client mode +# reload_config = "" # optional shortcut to reload config.toml without restarting # new_tab = "c" # rename_tab = "" # optional, unset by default # previous_tab = "" # optional, unset by default @@ -190,6 +191,7 @@ fn main() -> io::Result<()> { println!("Usage: herdr [options]"); println!(" herdr update"); println!(" herdr server stop"); + println!(" herdr server reload-config"); println!(" herdr workspace ..."); println!(" herdr tab ..."); println!(" herdr pane ..."); @@ -199,6 +201,7 @@ fn main() -> io::Result<()> { println!("Commands:"); println!(" server Run as headless server (no terminal, persists after client disconnect)"); println!(" server stop Stop the running server via the API socket"); + println!(" server reload-config Reload config.toml in the running server"); println!(" client Connect to a running server as a thin client"); println!( " update Download and install the latest version (run outside herdr)" @@ -322,17 +325,7 @@ fn main() -> io::Result<()> { })); let config = &loaded_config.config; - let config_diagnostic = if loaded_config.diagnostics.is_empty() { - None - } else if loaded_config.diagnostics.len() == 1 { - Some(loaded_config.diagnostics[0].clone()) - } else { - Some(format!( - "{} (and {} more)", - loaded_config.diagnostics[0], - loaded_config.diagnostics.len() - 1 - )) - }; + let config_diagnostic = config::config_diagnostic_summary(&loaded_config.diagnostics); logging::startup("app"); // Background update check (non-blocking, best-effort) diff --git a/src/server/headless.rs b/src/server/headless.rs index d5829f71..c2e814bf 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -512,9 +512,9 @@ impl HeadlessServer { needs_render = true; } - if self.app.state.request_reload_keybinds { - self.app.state.request_reload_keybinds = false; - self.app.reload_keybinds(); + if self.app.state.request_reload_config { + self.app.state.request_reload_config = false; + self.app.reload_config(); needs_render = true; } @@ -800,17 +800,11 @@ impl HeadlessServer { .unwrap_or(crate::detect::AgentState::Unknown); // Handle the state change (updates pane state, sets toast on AppState). - // Note: apply_pane_state_change inside handle_internal_event will try - // to play sounds, but sound.enabled=false in the headless server, so - // sound::play is never called. Toast may still be set on AppState if - // toast_config.enabled is true. + // Headless mode disables local sound playback separately from the + // sound policy so reloads can keep server-side notification policy live. self.app.handle_internal_event(ev); - // Forward sound notification to clients. - // We check the agent-specific sound setting but NOT sound.enabled, - // because the server sets enabled=false to prevent local playback — - // clients should still receive sound notifications and decide - // locally whether to play them based on their own config. + // Forward sound notification to clients when server-side sound policy allows it. let is_active_tab = self .app .state @@ -821,10 +815,7 @@ impl HeadlessServer { .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) }); - if !matches!( - self.app.state.sound.agents.for_agent(agent_val), - crate::config::AgentSoundSetting::Off - ) { + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( is_active_tab, prev_state, @@ -906,9 +897,10 @@ impl HeadlessServer { self.app.handle_internal_event(ev); - // Forward sound notification based on hook state transition. - // This ensures API-reported state changes (pane.report_agent) - // produce notifications even before fallback detection confirms. + // Forward sound notification based on hook state transition when + // server-side sound policy allows it. This ensures API-reported state + // changes (pane.report_agent) produce notifications even before + // fallback detection confirms. let is_active_tab = self .app .state @@ -919,10 +911,7 @@ impl HeadlessServer { .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) }); - if !matches!( - self.app.state.sound.agents.for_agent(agent_val), - crate::config::AgentSoundSetting::Off - ) { + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( is_active_tab, prev_hook_state, @@ -1261,8 +1250,8 @@ impl HeadlessServer { // forward any resulting notifications to connected clients. // API requests like pane.report_agent trigger handle_internal_event // internally, which bypasses drain_internal_events_with_forwarding. - // Since sound.enabled=false in the headless server, sounds would be - // silently dropped; toasts may be set but not forwarded. + // Headless mode disables local sound playback, so sound notifications + // need to be forwarded to clients here; toasts may be set but not forwarded. // // Note: pane.report_agent sets hook_authority on the pane, but the // effective state may not change until the fallback detector confirms @@ -1411,13 +1400,9 @@ impl HeadlessServer { } } - // Check agent-specific sound setting but NOT sound.enabled, - // because the server sets enabled=false to prevent local playback. - // Clients decide locally whether to play sounds. - if !matches!( - self.app.state.sound.agents.for_agent(agent), - crate::config::AgentSoundSetting::Off - ) { + // Forward sound notification when server-side sound policy allows it. + // Clients still decide locally whether they can execute the side effect. + if self.app.state.sound.allows(agent) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( is_active_tab, prev_state, @@ -1957,7 +1942,7 @@ pub fn run_server() -> io::Result<()> { let mut app = app::App::new( &loaded_config.config, no_session, - None, // config_diagnostic + config::config_diagnostic_summary(&loaded_config.diagnostics), None, // startup_release_notes api_rx, event_hub, @@ -1966,7 +1951,7 @@ pub fn run_server() -> io::Result<()> { // The server runs headless — disable local sound playback. // Sound notifications are forwarded to connected clients as // ServerMessage::Notify instead of played locally. - app.state.sound.enabled = false; + app.state.local_sound_playback = false; // Create the headless server. let mut server = match HeadlessServer::new(app) { diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index ccff95ab..2bb05c50 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -32,6 +32,10 @@ pub(super) fn keybind_help_groups( "navigate mode", ), ("prefix + ?".to_string(), "keybinds"), + ( + optional_keybind_label(&kb.reload_config_label), + "reload config", + ), ], )); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 92684bff..3f71d5d1 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -467,6 +467,16 @@ fn workspace_and_pane_management_commands_work() { let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); wait_for_socket(&socket_path, Duration::from_secs(5)); + let reloaded = run_cli(&socket_path, &["server", "reload-config"]); + assert!( + reloaded.status.success(), + "stderr: {}", + String::from_utf8_lossy(&reloaded.stderr) + ); + let reload_json: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap(); + assert_eq!(reload_json["result"]["type"], "config_reload"); + assert_eq!(reload_json["result"]["status"], "applied"); + let listed = run_cli(&socket_path, &["workspace", "list"]); assert!(listed.status.success()); let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 8860f65d..3ee16aea 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -11,6 +11,7 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; +use serde::Deserialize; use support::{ cleanup_test_base, client_handshake, encode_varint_u16, encode_varint_u32, frame_message, read_server_message, register_runtime_dir, register_spawned_herdr_pid, @@ -157,6 +158,71 @@ fn ping_socket(socket_path: &PathBuf) -> String { response.trim().to_string() } +#[derive(Debug, Deserialize)] +struct FrameWire { + cells: Vec, + width: u16, + height: u16, + cursor: Option, +} + +#[derive(Debug, Deserialize)] +struct CellWire { + symbol: String, + fg: u32, + bg: u32, + modifier: u16, + skip: bool, +} + +#[derive(Debug, Deserialize)] +struct CursorWire { + x: u16, + y: u16, + visible: bool, +} + +fn decode_frame_payload(payload: &[u8]) -> std::io::Result { + bincode::serde::decode_from_slice(payload, bincode::config::standard()) + .map(|(frame, consumed)| (frame, consumed)) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string())) + .and_then(|(frame, consumed): (FrameWire, usize)| { + if consumed != payload.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "frame payload had trailing bytes: consumed={}, len={}", + consumed, + payload.len() + ), + )); + } + Ok(frame) + }) +} + +fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool { + if frame.cells.is_empty() { + return false; + } + + let width = frame.width.max(1) as usize; + let mut text = String::new(); + for row in frame.cells.chunks(width) { + for cell in row { + let _ = (cell.fg, cell.bg, cell.modifier, cell.skip); + text.push_str(&cell.symbol); + } + text.push('\n'); + } + let _ = frame.height; + if let Some(cursor) = frame.cursor.as_ref() { + let _ = (cursor.x, cursor.y, cursor.visible); + } + + text.contains(needle) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -206,6 +272,91 @@ fn client_connects_and_receives_frame() { cleanup_spawned_herdr(spawned, base); } +#[test] +fn client_sees_headless_startup_config_diagnostic() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + + let app_dir = if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + }; + fs::create_dir_all(config_home.join(app_dir)).unwrap(); + fs::write( + config_home.join(app_dir).join("config.toml"), + "[keys\nprefix = \"ctrl+a\"\n", + ) + .unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + register_runtime_dir(&runtime_dir); + + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + + let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + cmd.arg("server"); + cmd.env("XDG_CONFIG_HOME", &config_home); + cmd.env("XDG_RUNTIME_DIR", &runtime_dir); + cmd.env("HERDR_SOCKET_PATH", &api_socket); + cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); + cmd.env("SHELL", "/bin/sh"); + cmd.env_remove("HERDR_ENV"); + + let child = pair.slave.spawn_command(cmd).unwrap(); + register_spawned_herdr_pid(child.process_id()); + drop(pair.slave); + + let spawned = SpawnedHerdr { + _master: pair.master, + child, + }; + wait_for_socket(&api_socket, Duration::from_secs(10)); + wait_for_file(&client_socket, Duration::from_secs(10)); + + let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); + let (version, error) = + client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 1); + assert!(error.is_none(), "{:?}", error); + + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let mut found_diagnostic = false; + while Instant::now() < deadline { + match read_server_message(&mut stream) { + Ok((1, payload)) => { + let frame = decode_frame_payload(&payload).expect("decode frame"); + if frame_contains_text(&frame, "config parse error") { + found_diagnostic = true; + break; + } + } + Ok(_) => {} + Err(_) => break, + } + } + + assert!( + found_diagnostic, + "attached client should see startup config parse diagnostic" + ); + + cleanup_spawned_herdr(spawned, base); +} + #[test] fn client_input_forwarded_to_pane() { // Stdin input is forwarded to server as ClientMessage::Input.