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 /src | |
| parent | 3aa7903d28f94e67c14dd5cad48bbe942fbef2e9 (diff) | |
feat: refine live config lifecycle
Diffstat (limited to 'src')
| -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 |
10 files changed, 462 insertions, 109 deletions
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