aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-26 04:01:01 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-26 17:06:00 +0300
commite9547a8e0de21d0ce81867b06511b68def16aff2 (patch)
tree8a025301146434b79ff295ff0d0afc833c682502 /src
parent3aa7903d28f94e67c14dd5cad48bbe942fbef2e9 (diff)
feat: refine live config lifecycle
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs2
-rw-r--r--src/app/input/sidebar.rs1
-rw-r--r--src/app/mod.rs266
-rw-r--r--src/app/state.rs13
-rw-r--r--src/client/mod.rs23
-rw-r--r--src/config/io.rs93
-rw-r--r--src/config/model.rs1
-rw-r--r--src/config/sound.rs4
-rw-r--r--src/server/headless.rs121
-rw-r--r--src/server/protocol.rs47
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![
ClientMessage::Hello {
- version: 1,
+ version: PROTOCOL_VERSION,
cols: 200,
rows: 60,
},