diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-26 04:01:01 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-26 17:06:00 +0300 |
| commit | e9547a8e0de21d0ce81867b06511b68def16aff2 (patch) | |
| tree | 8a025301146434b79ff295ff0d0afc833c682502 | |
| parent | 3aa7903d28f94e67c14dd5cad48bbe942fbef2e9 (diff) | |
feat: refine live config lifecycle
| -rw-r--r-- | CONFIGURATION.md | 6 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 2 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 1 | ||||
| -rw-r--r-- | src/app/mod.rs | 266 | ||||
| -rw-r--r-- | src/app/state.rs | 13 | ||||
| -rw-r--r-- | src/client/mod.rs | 23 | ||||
| -rw-r--r-- | src/config/io.rs | 93 | ||||
| -rw-r--r-- | src/config/model.rs | 1 | ||||
| -rw-r--r-- | src/config/sound.rs | 4 | ||||
| -rw-r--r-- | src/server/headless.rs | 121 | ||||
| -rw-r--r-- | src/server/protocol.rs | 47 | ||||
| -rw-r--r-- | tests/client_mode.rs | 36 | ||||
| -rw-r--r-- | tests/cross_area.rs | 16 | ||||
| -rw-r--r-- | tests/detach_reattach.rs | 40 | ||||
| -rw-r--r-- | tests/multi_client.rs | 2 | ||||
| -rw-r--r-- | tests/server_headless.rs | 16 |
16 files changed, 520 insertions, 167 deletions
diff --git a/CONFIGURATION.md b/CONFIGURATION.md index a80e6bd0..a494d0cc 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -31,15 +31,15 @@ Reloadable now: - theme, custom theme colors, and legacy `ui.accent` - `ui.confirm_close` - `ui.toast.delivery` -- server-side `ui.sound` policy +- server-side `ui.sound` policy; attached thin clients refresh local sound config after a successful sound-policy change - `advanced.scrollback_limit_bytes` for panes created after reload -- `ui.sidebar_width` as the default width +- `ui.sidebar_width` as the default width; current width updates only while it is still config-owned 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 +- terminal notifications and sounds are client-local side effects and are sent to the foreground attached client 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. diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 9676da62..e2454861 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -138,6 +138,8 @@ impl App { if is_double_click { self.state.sidebar_width = self.state.default_sidebar_width; + self.state.sidebar_width_source = + crate::app::state::SidebarWidthSource::ConfigDefault; self.state.sidebar_width_auto = false; self.state.mark_session_dirty(); self.state.drag = None; diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 0abfd446..b806271e 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -242,6 +242,7 @@ impl AppState { let width = divider_col.saturating_sub(sidebar.x).saturating_add(1); self.sidebar_width = width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH); + self.sidebar_width_source = crate::app::state::SidebarWidthSource::Manual; self.mark_session_dirty(); } diff --git a/src/app/mod.rs b/src/app/mod.rs index c4806a77..26090ebf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -116,6 +116,13 @@ fn auto_updates_enabled(no_session: bool) -> bool { /// Resolve the palette from config: base theme + optional custom overrides. fn resolve_palette(config: &crate::config::Config) -> state::Palette { + resolve_palette_with_legacy_accent(config, true) +} + +fn resolve_palette_with_legacy_accent( + config: &crate::config::Config, + use_legacy_ui_accent: bool, +) -> state::Palette { // Start with the named theme (default: catppuccin) let base_name = config.theme.name.as_deref().unwrap_or("catppuccin"); let mut palette = state::Palette::from_name(base_name).unwrap_or_else(|| { @@ -132,7 +139,8 @@ fn resolve_palette(config: &crate::config::Config) -> state::Palette { } // Legacy: if ui.accent is set and no theme.custom.accent, use it for compat - if config.ui.accent != "cyan" + if use_legacy_ui_accent + && config.ui.accent != "cyan" && config .theme .custom @@ -161,59 +169,78 @@ impl App { let render_dirty = Arc::new(AtomicBool::new(false)); // Try to restore previous session - let (workspaces, active, selected, agent_panel_scope, sidebar_width, sidebar_section_split) = - if no_session { + let ( + workspaces, + active, + selected, + agent_panel_scope, + sidebar_width, + sidebar_width_source, + sidebar_section_split, + ) = if no_session { + ( + Vec::new(), + None, + 0, + state::AgentPanelScope::CurrentWorkspace, + config.ui.sidebar_width, + state::SidebarWidthSource::ConfigDefault, + 0.5_f32, + ) + } else if let Some(snap) = crate::persist::load() { + let ws = crate::persist::restore( + &snap, + 24, + 80, + config.advanced.scrollback_limit_bytes, + event_tx.clone(), + render_notify.clone(), + render_dirty.clone(), + ); + if ws.is_empty() { + crate::logging::session_restored(0, "empty"); ( Vec::new(), None, 0, - state::AgentPanelScope::CurrentWorkspace, - config.ui.sidebar_width, - 0.5_f32, + snap.agent_panel_scope, + snap.sidebar_width.unwrap_or(config.ui.sidebar_width), + if snap.sidebar_width.is_some() { + state::SidebarWidthSource::Persisted + } else { + state::SidebarWidthSource::ConfigDefault + }, + snap.sidebar_section_split.unwrap_or(0.5), ) - } else if let Some(snap) = crate::persist::load() { - let ws = crate::persist::restore( - &snap, - 24, - 80, - config.advanced.scrollback_limit_bytes, - event_tx.clone(), - render_notify.clone(), - render_dirty.clone(), - ); - if ws.is_empty() { - crate::logging::session_restored(0, "empty"); - ( - Vec::new(), - None, - 0, - snap.agent_panel_scope, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - snap.sidebar_section_split.unwrap_or(0.5), - ) - } else { - crate::logging::session_restored(ws.len(), "ok"); - let active = snap.active.filter(|&i| i < ws.len()); - let selected = snap.selected.min(ws.len().saturating_sub(1)); - ( - ws, - active, - selected, - snap.agent_panel_scope, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - snap.sidebar_section_split.unwrap_or(0.5), - ) - } } else { + crate::logging::session_restored(ws.len(), "ok"); + let active = snap.active.filter(|&i| i < ws.len()); + let selected = snap.selected.min(ws.len().saturating_sub(1)); ( - Vec::new(), - None, - 0, - state::AgentPanelScope::CurrentWorkspace, - config.ui.sidebar_width, - 0.5_f32, + ws, + active, + selected, + snap.agent_panel_scope, + snap.sidebar_width.unwrap_or(config.ui.sidebar_width), + if snap.sidebar_width.is_some() { + state::SidebarWidthSource::Persisted + } else { + state::SidebarWidthSource::ConfigDefault + }, + snap.sidebar_section_split.unwrap_or(0.5), ) - }; + } + } else { + ( + Vec::new(), + None, + 0, + state::AgentPanelScope::CurrentWorkspace, + config.ui.sidebar_width, + state::SidebarWidthSource::ConfigDefault, + 0.5_f32, + ) + }; info!( pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes, @@ -248,6 +275,7 @@ impl App { request_new_workspace: false, request_new_tab: false, request_reload_config: false, + request_client_sound_config_reload: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, @@ -291,6 +319,7 @@ impl App { prefix_mods, default_sidebar_width: config.ui.sidebar_width, sidebar_width, + sidebar_width_source, sidebar_width_auto: false, sidebar_collapsed: false, sidebar_section_split, @@ -542,7 +571,12 @@ impl App { ) -> crate::config::ConfigReloadReport { let previous_toast = self.state.toast.clone(); let report = match crate::config::load_live_config() { - Ok(loaded) => self.apply_live_config(&loaded.config, notify_success), + Ok(loaded) => self.apply_live_config( + &loaded.config, + &loaded.diagnostics, + &loaded.invalid_sections, + notify_success, + ), Err(diagnostics) => { self.state.toast = None; self.state.config_diagnostic = @@ -561,43 +595,59 @@ impl App { fn apply_live_config( &mut self, config: &crate::config::Config, + load_diagnostics: &[String], + invalid_sections: &[String], notify_success: bool, ) -> crate::config::ConfigReloadReport { - let mut diagnostics = Vec::new(); + let mut diagnostics = load_diagnostics.to_vec(); + let invalid_section = + |section: &str| invalid_sections.iter().any(|invalid| invalid == section); + + if !invalid_section("keys") { + 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; + } + Err(keybind_diagnostics) => { + let mut message = keybind_diagnostics.join("; "); + if !message.contains("keeping current keybinds") { + message.push_str("; keeping current keybinds"); + } + diagnostics.push(message); + } + } + } + + if !invalid_section("ui") { + diagnostics.extend(config.ui.sound.diagnostics()); - 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.default_sidebar_width = config.ui.sidebar_width; + if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { + self.state.sidebar_width = config.ui.sidebar_width; } - Err(keybind_diagnostics) => { - let mut message = keybind_diagnostics.join("; "); - if !message.contains("keeping current keybinds") { - message.push_str("; keeping current keybinds"); - } - diagnostics.push(message); + self.state.confirm_close = config.ui.confirm_close; + self.state.accent = crate::config::parse_color(&config.ui.accent); + if !self.state.local_sound_playback && self.state.sound != config.ui.sound { + self.state.request_client_sound_config_reload = true; } + self.state.sound = config.ui.sound.clone(); + self.state.toast_config = config.ui.toast.clone(); } - diagnostics.extend(config.ui.sound.diagnostics()); + if !invalid_section("advanced") { + self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes; + } - 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; + if !invalid_section("theme") { + self.state.palette = resolve_palette_with_legacy_accent(config, !invalid_section("ui")); + self.state.theme_name = config + .theme + .name + .clone() + .unwrap_or_else(|| "catppuccin".to_string()); } - 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 @@ -904,6 +954,37 @@ mod tests { } #[test] + fn reload_config_updates_sidebar_width_only_when_config_owned() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-sidebar-width"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + assert_eq!( + app.state.sidebar_width_source, + state::SidebarWidthSource::ConfigDefault + ); + + std::fs::write(&path, "[ui]\nsidebar_width = 42\n").unwrap(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!(app.state.default_sidebar_width, 42); + assert_eq!(app.state.sidebar_width, 42); + + app.state.sidebar_width = 31; + app.state.sidebar_width_source = state::SidebarWidthSource::Manual; + std::fs::write(&path, "[ui]\nsidebar_width = 44\n").unwrap(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!(app.state.default_sidebar_width, 44); + assert_eq!(app.state.sidebar_width, 31); + + 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_keybinds_on_invalid_binding_but_applies_other_sections() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-config-invalid-keybind"); @@ -944,6 +1025,41 @@ mod tests { } #[test] + fn reload_config_preserves_invalid_ui_section_but_applies_valid_keys() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-invalid-ui-section"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + "[keys]\nnew_workspace = \"g\"\n[ui.toast]\ndelivery = \"desktop\"\n", + ) + .unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + let report = app.reload_config(); + + assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + 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 + .as_deref() + .is_some_and(|message| message.contains("invalid ui config"))); + + 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"); diff --git a/src/app/state.rs b/src/app/state.rs index a51a72e7..971b1476 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -578,6 +578,13 @@ pub struct KeybindHelpState { pub scroll: u16, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SidebarWidthSource { + ConfigDefault, + Persisted, + Manual, +} + /// All application state — pure data, no channels or async runtime. /// Testable without PTYs or a tokio runtime. pub struct AppState { @@ -594,6 +601,9 @@ pub struct AppState { pub request_new_workspace: bool, pub request_new_tab: bool, pub request_reload_config: bool, + /// Set when the headless server should ask attached clients to reload + /// their client-local sound config from disk. + pub request_client_sound_config_reload: 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<Vec<u8>>, @@ -626,6 +636,7 @@ pub struct AppState { pub prefix_mods: KeyModifiers, pub default_sidebar_width: u16, pub sidebar_width: u16, + pub sidebar_width_source: SidebarWidthSource, pub sidebar_width_auto: bool, pub sidebar_collapsed: bool, /// Ratio of sidebar height allocated to the workspaces section. @@ -719,6 +730,7 @@ impl AppState { request_new_workspace: false, request_new_tab: false, request_reload_config: false, + request_client_sound_config_reload: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, @@ -757,6 +769,7 @@ impl AppState { prefix_mods: KeyModifiers::CONTROL, default_sidebar_width: 26, sidebar_width: 26, + sidebar_width_source: SidebarWidthSource::ConfigDefault, sidebar_width_auto: false, sidebar_collapsed: false, sidebar_section_split: 0.5, diff --git a/src/client/mod.rs b/src/client/mod.rs index add890c9..19d07bfb 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -43,6 +43,8 @@ struct ClientState { last_frame: Option<FrameData>, /// The terminal size we reported to the server in our last Hello/Resize. reported_size: (u16, u16), + /// Client-local sound playback config, refreshed on server request. + sound_config: crate::config::SoundConfig, } // --------------------------------------------------------------------------- @@ -355,6 +357,7 @@ async fn run_client_loop( let mut state = ClientState { last_frame: None, reported_size: (cols, rows), + sound_config, }; // Channel for events from the stdin, resize, and server reader threads. @@ -426,12 +429,15 @@ async fn run_client_loop( return Err(ClientError::ServerShutdown { reason }); } ServerMessage::Notify { kind, message } => { - handle_notify(kind, &message, &sound_config); + handle_notify(kind, &message, &state.sound_config); } ServerMessage::Clipboard { data } => { forward_clipboard(&data); let _ = io::stdout().flush(); } + ServerMessage::ReloadSoundConfig => { + reload_local_sound_config(&mut state.sound_config); + } ServerMessage::Welcome { .. } => { debug!("received unexpected Welcome in main loop"); } @@ -524,6 +530,21 @@ fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<( // Notifications // --------------------------------------------------------------------------- +fn reload_local_sound_config(sound_config: &mut crate::config::SoundConfig) { + match crate::config::load_live_config() { + Ok(loaded) => { + for diagnostic in loaded.config.ui.sound.diagnostics() { + warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + } + *sound_config = loaded.config.ui.sound; + debug!("reloaded local sound config"); + } + Err(diagnostics) => { + warn!(diagnostics = ?diagnostics, "failed to reload local sound config; keeping current sound config"); + } + } +} + fn handle_notify(kind: NotifyKind, message: &str, sound_config: &crate::config::SoundConfig) { handle_notify_with_terminal_notifier( kind, diff --git a/src/config/io.rs b/src/config/io.rs index 2774fc93..16602972 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -33,6 +33,7 @@ impl Config { return LoadedConfig { config, diagnostics, + invalid_sections: Vec::new(), }; } Err(err) => { @@ -40,6 +41,7 @@ impl Config { return LoadedConfig { config: Self::default(), diagnostics: vec![format!("config parse error: {err}; using defaults")], + invalid_sections: Vec::new(), }; } }, @@ -48,6 +50,7 @@ impl Config { return LoadedConfig { config: Self::default(), diagnostics: vec![format!("config read error: {err}; using defaults")], + invalid_sections: Vec::new(), }; } } @@ -55,6 +58,7 @@ impl Config { LoadedConfig { config: Self::default(), diagnostics: Vec::new(), + invalid_sections: Vec::new(), } } } @@ -97,19 +101,104 @@ pub fn load_live_config() -> Result<LoadedConfig, Vec<String>> { return Ok(LoadedConfig { config: Config::default(), diagnostics: Vec::new(), + invalid_sections: Vec::new(), }); } 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::<Config>(&content) + load_live_config_from_str(&content) +} + +fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>> { + let value = content + .parse::<toml::Value>() .map_err(|err| vec![format!("config parse error: {err}; keeping current config")])?; + let table = value.as_table().ok_or_else(|| { + vec![ + "config parse error: top-level config must be a table; keeping current config" + .to_string(), + ] + })?; + + let mut config = Config::default(); + let mut diagnostics = Vec::new(); + let mut invalid_sections = Vec::new(); + + if let Some(value) = table.get("onboarding") { + match value.clone().try_into::<Option<bool>>() { + Ok(onboarding) => config.onboarding = onboarding, + Err(err) => diagnostics.push(format!( + "invalid onboarding setting: {err}; keeping current onboarding state" + )), + } + } + + load_live_section( + table, + "theme", + "theme config", + &mut diagnostics, + &mut invalid_sections, + |section| config.theme = section, + ); + load_live_section( + table, + "keys", + "keybinding config", + &mut diagnostics, + &mut invalid_sections, + |section| config.keys = section, + ); + load_live_section( + table, + "ui", + "ui config", + &mut diagnostics, + &mut invalid_sections, + |section| config.ui = section, + ); + load_live_section( + table, + "advanced", + "advanced config", + &mut diagnostics, + &mut invalid_sections, + |section| config.advanced = section, + ); + Ok(LoadedConfig { config, - diagnostics: Vec::new(), + diagnostics, + invalid_sections, }) } +fn load_live_section<T>( + table: &toml::map::Map<String, toml::Value>, + section: &'static str, + label: &str, + diagnostics: &mut Vec<String>, + invalid_sections: &mut Vec<String>, + apply: impl FnOnce(T), +) where + T: serde::de::DeserializeOwned, +{ + let Some(value) = table.get(section) else { + return; + }; + + match value.clone().try_into::<T>() { + Ok(section_config) => apply(section_config), + Err(err) => { + diagnostics.push(format!( + "invalid {label}: {err}; keeping current {section} settings" + )); + invalid_sections.push(section.to_string()); + } + } +} + pub(crate) 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(); diff --git a/src/config/model.rs b/src/config/model.rs index 32d60493..5a17bea7 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -44,6 +44,7 @@ pub struct Config { pub struct LoadedConfig { pub config: Config, pub diagnostics: Vec<String>, + pub invalid_sections: Vec<String>, } #[derive(Debug, Deserialize)] diff --git a/src/config/sound.rs b/src/config/sound.rs index 063a521b..6219d698 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -6,7 +6,7 @@ use crate::detect::Agent; use super::io::resolve_config_relative_path; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(default)] pub struct SoundConfig { pub enabled: bool, @@ -22,7 +22,7 @@ pub struct SoundConfig { pub agents: AgentSoundOverrides, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(default)] pub struct AgentSoundOverrides { pub pi: AgentSoundSetting, diff --git a/src/server/headless.rs b/src/server/headless.rs index c2e814bf..b37538df 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -518,6 +518,8 @@ impl HeadlessServer { needs_render = true; } + self.drain_client_sound_config_reload_request(); + self.app.sync_headless_animation_timer(now); // 7. Render virtually and stream frames. @@ -825,7 +827,7 @@ impl HeadlessServer { crate::sound::Sound::Done => "agent done", crate::sound::Sound::Request => "agent attention", }; - self.send_to_all_clients(ServerMessage::Notify { + self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Sound, message: msg.to_owned(), }); @@ -854,7 +856,7 @@ impl HeadlessServer { }; if let Some(msg) = toast_msg { - self.send_to_all_clients(ServerMessage::Notify { + self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Toast, message: msg, }); @@ -921,7 +923,7 @@ impl HeadlessServer { crate::sound::Sound::Done => "agent done", crate::sound::Sound::Request => "agent attention", }; - self.send_to_all_clients(ServerMessage::Notify { + self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Sound, message: msg.to_owned(), }); @@ -950,7 +952,7 @@ impl HeadlessServer { }; if let Some(msg) = toast_msg { - self.send_to_all_clients(ServerMessage::Notify { + self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Toast, message: msg, }); @@ -982,7 +984,7 @@ impl HeadlessServer { }; if let Some(msg) = toast_msg { - self.send_to_all_clients(ServerMessage::Notify { + self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Toast, message: msg, }); @@ -1010,9 +1012,9 @@ impl HeadlessServer { /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the /// foreground client only. /// - Detect when a sound would be played and forward as - /// `ServerMessage::Notify { kind: Sound }` to all clients. + /// `ServerMessage::Notify { kind: Sound }` to the foreground client. /// - Detect when a toast is set on AppState and forward as - /// `ServerMessage::Notify { kind: Toast }` to all clients. + /// `ServerMessage::Notify { kind: Toast }` to the foreground client. fn drain_internal_events_with_forwarding(&mut self) -> bool { let mut changed = false; while let Ok(ev) = self.app.event_rx.try_recv() { @@ -1021,6 +1023,14 @@ impl HeadlessServer { changed } + fn drain_client_sound_config_reload_request(&mut self) { + if !self.app.state.request_client_sound_config_reload { + return; + } + self.app.state.request_client_sound_config_reload = false; + self.send_to_all_clients(ServerMessage::ReloadSoundConfig); + } + /// Encodes a server message into a length-prefixed frame. fn frame_server_message(msg: &ServerMessage) -> Result<Vec<u8>, protocol::FramingError> { let mut framed = Vec::new(); @@ -1058,6 +1068,14 @@ impl HeadlessServer { } } + /// Sends a client-local side effect to the foreground client only. + fn send_to_foreground_client(&mut self, msg: ServerMessage) -> bool { + let Some(client_id) = self.foreground_client_id else { + return false; + }; + self.send_to_client(client_id, msg) + } + /// Sends a message to a specific client. Returns false if the client /// was not found or the send failed (client removed). fn send_to_client(&mut self, client_id: u64, msg: ServerMessage) -> bool { @@ -1298,7 +1316,7 @@ impl HeadlessServer { if let Some(toast) = &toast_after { let msg_text = format!("{}: {}", toast.title, toast.context); debug!(msg = %msg_text, "forwarding toast notification from API request"); - self.send_to_all_clients(ServerMessage::Notify { + self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Toast, message: msg_text, }); @@ -1392,7 +1410,7 @@ impl HeadlessServer { *pane_id, ) ); - self.send_to_all_clients(ServerMessage::Notify { + self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Toast, message: msg_text, }); @@ -1413,7 +1431,7 @@ impl HeadlessServer { crate::sound::Sound::Request => "agent attention", }; debug!(sound = ?sound, "forwarding sound notification from API request"); - self.send_to_all_clients(ServerMessage::Notify { + self.send_to_foreground_client(ServerMessage::Notify { kind: protocol::NotifyKind::Sound, message: msg_text.to_owned(), }); @@ -2370,6 +2388,36 @@ mod tests { } #[test] + fn client_sound_reload_request_refreshes_attached_clients() { + let mut server = test_headless_server(); + let (client_tx, client_rx) = std::sync::mpsc::channel(); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + last_activity: 1, + last_frame: None, + writer: Some(client_tx), + }, + ); + server.app.state.request_client_sound_config_reload = true; + + server.drain_client_sound_config_reload_request(); + + match read_server_message( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("client sound reload message"), + ) { + ServerMessage::ReloadSoundConfig => {} + other => panic!("expected ReloadSoundConfig, got {other:?}"), + } + assert!(!server.app.state.request_client_sound_config_reload); + } + + #[test] fn clipboard_write_targets_foreground_client_only() { let mut server = test_headless_server(); let (background_tx, background_rx) = std::sync::mpsc::channel(); @@ -2420,6 +2468,59 @@ mod tests { } #[test] + fn client_local_notifications_target_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_rx) = std::sync::mpsc::channel(); + let (foreground_tx, foreground_rx) = std::sync::mpsc::channel(); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (120, 40), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + last_activity: 1, + last_frame: None, + writer: Some(background_tx), + }, + ); + server.clients.insert( + 2, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + last_activity: 2, + last_frame: None, + writer: Some(foreground_tx), + }, + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + assert!(server.send_to_foreground_client(ServerMessage::Notify { + kind: protocol::NotifyKind::Toast, + message: "pi finished: workspace 1".to_string(), + })); + + match read_server_message( + foreground_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground toast message"), + ) { + ServerMessage::Notify { kind, message } => { + assert_eq!(kind, protocol::NotifyKind::Toast); + assert_eq!(message, "pi finished: workspace 1"); + } + other => panic!("expected toast notify, got {other:?}"), + } + assert!( + background_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive client-local notifications" + ); + } + + #[test] fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() { let mut server = test_headless_server(); let (client_tx, client_rx) = std::sync::mpsc::channel(); diff --git a/src/server/protocol.rs b/src/server/protocol.rs index f3784a5e..ed0c6961 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -209,6 +209,9 @@ pub enum ServerMessage { /// Base64-encoded clipboard data. data: String, }, + + /// Client-local sound config changed on disk; refresh it without reconnecting. + ReloadSoundConfig, } // --------------------------------------------------------------------------- @@ -476,7 +479,7 @@ mod tests { #[test] fn client_hello_roundtrip() { let msg = ClientMessage::Hello { - version: 1, + version: PROTOCOL_VERSION, cols: 80, rows: 24, }; @@ -520,7 +523,7 @@ mod tests { #[test] fn server_welcome_roundtrip() { let msg = ServerMessage::Welcome { - version: 1, + version: PROTOCOL_VERSION, error: None, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); @@ -532,7 +535,7 @@ mod tests { #[test] fn server_welcome_with_error_roundtrip() { let msg = ServerMessage::Welcome { - version: 1, + version: PROTOCOL_VERSION, error: Some("incompatible version".to_owned()), }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); @@ -638,12 +641,21 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn server_reload_sound_config_roundtrip() { + let msg = ServerMessage::ReloadSoundConfig; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + // ---- Framing ---- #[test] fn framing_small_message_roundtrip() { let msg = ClientMessage::Hello { - version: 1, + version: PROTOCOL_VERSION, cols: 80, rows: 24, }; @@ -708,7 +720,7 @@ mod tests { for i in 0..150u32 { let msg = match i % 4 { 0 => ClientMessage::Hello { - version: 1, + version: PROTOCOL_VERSION, cols: (80 + (i % 40) as u16), rows: (24 + (i % 20) as u16), }, @@ -820,22 +832,19 @@ mod tests { #[test] fn version_compatible() { - assert_eq!(check_client_version(1), VersionCheck::Compatible); + assert_eq!( + check_client_version(PROTOCOL_VERSION), + VersionCheck::Compatible + ); } #[test] fn version_older_client_rejected() { - match check_client_version(1) { - VersionCheck::Compatible => {} // current version - _ => panic!("current version should be compatible"), + let result = check_client_version(PROTOCOL_VERSION - 1); + assert!(matches!(result, VersionCheck::Incompatible(_))); + if let VersionCheck::Incompatible(msg) = result { + assert!(msg.contains("older"), "error should mention older version"); } - // If we imagine a future server version 2, a v1 client would be rejected. - // We test this by checking the logic directly. - let result = check_client_version(0); - assert!( - matches!(result, VersionCheck::Incompatible(_)), - "version 0 should be incompatible" - ); } #[test] @@ -1119,7 +1128,7 @@ mod tests { fn read_message_accepts_exact_payload() { // A normally-framed message should decode without error. let msg = ClientMessage::Hello { - version: 1, + version: PROTOCOL_VERSION, cols: 80, rows: 24, }; @@ -1149,7 +1158,7 @@ mod tests { let messages = vec