aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-26 02:07:33 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-26 02:07:33 +0300
commit3d1ef155bf9846f39725bf22c54666da2bc42330 (patch)
treef4b4c3eca6c6f821dbfe86bcee0fd6a5d9ab9da9 /src
parentef4182efd9f15e6c13d91d45e525b094cdcee2fe (diff)
feat: add live config reload
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs4
-rw-r--r--src/api/schema.rs19
-rw-r--r--src/app/actions.rs2
-rw-r--r--src/app/api.rs10
-rw-r--r--src/app/config_io.rs25
-rw-r--r--src/app/input/modal.rs8
-rw-r--r--src/app/input/navigate.rs26
-rw-r--r--src/app/input/settings.rs8
-rw-r--r--src/app/input/sidebar.rs12
-rw-r--r--src/app/mod.rs203
-rw-r--r--src/app/state.rs8
-rw-r--r--src/cli.rs14
-rw-r--r--src/config.rs8
-rw-r--r--src/config/io.rs42
-rw-r--r--src/config/keybinds.rs50
-rw-r--r--src/config/model.rs17
-rw-r--r--src/main.rs15
-rw-r--r--src/server/headless.rs53
-rw-r--r--src/ui/keybind_help.rs4
19 files changed, 389 insertions, 139 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index cb4f120a..33a3cd44 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -30,7 +30,8 @@ const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) fn request_changes_ui(request: &Request) -> bool {
matches!(
&request.method,
- Method::WorkspaceCreate(_)
+ Method::ServerReloadConfig(_)
+ | Method::WorkspaceCreate(_)
| Method::WorkspaceFocus(_)
| Method::WorkspaceRename(_)
| Method::WorkspaceClose(_)
@@ -317,6 +318,7 @@ fn api_method_name(method: &Method) -> &'static str {
match method {
Method::Ping(_) => "ping",
Method::ServerStop(_) => "server.stop",
+ Method::ServerReloadConfig(_) => "server.reload_config",
Method::WorkspaceCreate(_) => "workspace.create",
Method::WorkspaceList(_) => "workspace.list",
Method::WorkspaceGet(_) => "workspace.get",
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 6cb81c12..f7665fd9 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -14,6 +14,8 @@ pub enum Method {
Ping(PingParams),
#[serde(rename = "server.stop")]
ServerStop(EmptyParams),
+ #[serde(rename = "server.reload_config")]
+ ServerReloadConfig(EmptyParams),
#[serde(rename = "workspace.create")]
WorkspaceCreate(WorkspaceCreateParams),
#[serde(rename = "workspace.list")]
@@ -475,6 +477,10 @@ pub enum ResponseResult {
target: IntegrationTarget,
details: IntegrationUninstallResult,
},
+ ConfigReload {
+ status: crate::config::ConfigReloadStatus,
+ diagnostics: Vec<String>,
+ },
Ok {},
}
@@ -766,6 +772,19 @@ mod tests {
}
#[test]
+ fn request_round_trips_for_server_reload_config() {
+ let request = Request {
+ id: "req_reload".into(),
+ method: Method::ServerReloadConfig(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.reload_config");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
fn unknown_method_is_rejected() {
let json = r#"{"id":"req_1","method":"nope","params":{}}"#;
let err = serde_json::from_str::<Request>(json)
diff --git a/src/app/actions.rs b/src/app/actions.rs
index b1bdfc63..210b6ccf 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -587,7 +587,7 @@ impl AppState {
pane.seen = false;
}
- if self.sound.allows(change.known_agent) {
+ if self.local_sound_playback && self.sound.allows(change.known_agent) {
if let Some(sound) = notification_sound_for_state_change(
is_active_tab,
change.previous_state,
diff --git a/src/app/api.rs b/src/app/api.rs
index 364097dc..d7893d6d 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -282,6 +282,16 @@ impl App {
result: ResponseResult::Ok {},
}
}
+ Method::ServerReloadConfig(_) => {
+ let report = self.reload_config();
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::ConfigReload {
+ status: report.status,
+ diagnostics: report.diagnostics,
+ },
+ }
+ }
Method::WorkspaceList(_) => SuccessResponse {
id: request.id,
result: ResponseResult::WorkspaceList {
diff --git a/src/app/config_io.rs b/src/app/config_io.rs
index 6770d61e..6c51ad9f 100644
--- a/src/app/config_io.rs
+++ b/src/app/config_io.rs
@@ -1,7 +1,7 @@
use super::App;
impl App {
- pub(super) fn update_config_file<F>(&mut self, error_context: &str, update: F)
+ pub(super) fn update_config_file<F>(&mut self, error_context: &str, update: F) -> bool
where
F: FnOnce(&str) -> String,
{
@@ -13,7 +13,7 @@ impl App {
Some(format!("failed to save {error_context}: {err}"));
self.config_diagnostic_deadline =
Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
- return;
+ return false;
}
}
@@ -24,7 +24,10 @@ impl App {
self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}"));
self.config_diagnostic_deadline =
Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
+ return false;
}
+
+ true
}
pub(super) fn mark_onboarding_complete(&mut self) {
@@ -34,15 +37,19 @@ impl App {
}
pub(super) fn save_theme(&mut self, name: &str) {
- self.update_config_file("theme", |content| {
+ if self.update_config_file("theme", |content| {
crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\""))
- });
+ }) {
+ self.apply_config_from_disk(false);
+ }
}
pub(super) fn save_sound(&mut self, enabled: bool) {
- self.update_config_file("sound setting", |content| {
+ if self.update_config_file("sound setting", |content| {
crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled)
- });
+ }) {
+ self.apply_config_from_disk(false);
+ }
}
pub(super) fn save_toast_delivery(&mut self, delivery: crate::config::ToastDelivery) {
@@ -51,10 +58,12 @@ impl App {
crate::config::ToastDelivery::Herdr => "\"herdr\"",
crate::config::ToastDelivery::Terminal => "\"terminal\"",
};
- self.update_config_file("toast setting", |content| {
+ if self.update_config_file("toast setting", |content| {
let content =
crate::config::upsert_section_value(content, "ui.toast", "delivery", value);
crate::config::remove_section_key(&content, "ui.toast", "enabled")
- });
+ }) {
+ self.apply_config_from_disk(false);
+ }
}
}
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index aa25ea1e..998bb69b 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -69,7 +69,7 @@ pub(crate) enum GlobalMenuAction {
Quit,
WhatsNew,
Keybinds,
- ReloadKeybinds,
+ ReloadConfig,
Settings,
}
@@ -77,7 +77,7 @@ pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> {
let mut actions = vec![
GlobalMenuAction::Settings,
GlobalMenuAction::Keybinds,
- GlobalMenuAction::ReloadKeybinds,
+ GlobalMenuAction::ReloadConfig,
];
if state.update_available.is_some() || state.latest_release_notes_available {
actions.push(GlobalMenuAction::WhatsNew);
@@ -126,8 +126,8 @@ pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuA
}
GlobalMenuAction::WhatsNew => open_update_release_notes(state),
GlobalMenuAction::Keybinds => open_keybind_help(state),
- GlobalMenuAction::ReloadKeybinds => {
- state.request_reload_keybinds = true;
+ GlobalMenuAction::ReloadConfig => {
+ state.request_reload_config = true;
leave_modal(state);
}
GlobalMenuAction::Settings => super::settings::open_settings(state),
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 23b93379..b961c6d3 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -350,6 +350,7 @@ pub(crate) enum NavigateAction {
Fullscreen,
EnterResizeMode,
ToggleSidebar,
+ ReloadConfig,
Detach,
}
@@ -422,6 +423,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA
return Some(NavigateAction::ToggleSidebar);
}
if kb
+ .reload_config
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::ReloadConfig);
+ }
+ if kb
.detach
.is_some_and(|(code, mods)| key_matches(key, code, mods))
{
@@ -498,6 +505,10 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
state.sidebar_collapsed = !state.sidebar_collapsed;
leave_navigate_mode(state);
}
+ NavigateAction::ReloadConfig => {
+ state.request_reload_config = true;
+ leave_navigate_mode(state);
+ }
NavigateAction::Detach => {
state.detach_requested = true;
leave_navigate_mode(state);
@@ -583,6 +594,21 @@ mod tests {
}
#[test]
+ fn custom_reload_config_key_requests_reload_and_exits_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.reload_config = Some((KeyCode::Char('g'), KeyModifiers::empty()));
+ state.keybinds.reload_config_label = Some("g".into());
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert!(state.request_reload_config);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
fn movement_action_stays_in_navigate_mode() {
let mut state = state_with_workspaces(&["a", "b"]);
state.selected = 0;
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
index 8bf06dc6..2c92bc63 100644
--- a/src/app/input/settings.rs
+++ b/src/app/input/settings.rs
@@ -125,7 +125,6 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
}
KeyCode::Enter | KeyCode::Char(' ') => {
let enabled = state.settings.list.selected == 0;
- state.sound.enabled = enabled;
return Some(SettingsAction::SaveSound(enabled));
}
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
@@ -146,8 +145,6 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(3),
KeyCode::Enter | KeyCode::Char(' ') => {
let delivery = toast_delivery_for_index(state.settings.list.selected);
- state.toast_config.delivery = delivery;
- state.toast = None;
return Some(SettingsAction::SaveToastDelivery(delivery));
}
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
@@ -271,13 +268,10 @@ impl AppState {
}
SettingsSection::Sound => {
let enabled = idx == 0;
- self.sound.enabled = enabled;
Some(SettingsAction::SaveSound(enabled))
}
SettingsSection::Toast => {
let delivery = toast_delivery_for_index(idx);
- self.toast_config.delivery = delivery;
- self.toast = None;
Some(SettingsAction::SaveToastDelivery(delivery))
}
};
@@ -362,7 +356,7 @@ mod tests {
);
assert_eq!(action, Some(SettingsAction::SaveSound(true)));
- assert!(state.sound.enabled);
+ assert!(!state.sound.enabled);
assert_eq!(state.mode, Mode::Settings);
}
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index 9de8868b..0abfd446 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -182,7 +182,7 @@ impl AppState {
}
pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> {
- let mut labels = vec!["settings", "keybinds", "reload keybinds"];
+ let mut labels = vec!["settings", "keybinds", "reload config"];
if self.update_available.is_some() {
labels.push("update ready");
} else if self.latest_release_notes_available {
@@ -530,7 +530,7 @@ mod tests {
}
#[test]
- fn clicking_reload_keybinds_menu_item_requests_reload() {
+ fn clicking_reload_config_menu_item_requests_reload() {
let mut app = app_for_mouse_test();
let launcher = app.state.global_launcher_rect();
app.handle_mouse(mouse(
@@ -546,7 +546,7 @@ mod tests {
menu.y + 3,
));
- assert!(app.state.request_reload_keybinds);
+ assert!(app.state.request_reload_config);
assert_eq!(app.state.mode, Mode::Navigate);
}
@@ -568,7 +568,7 @@ mod tests {
vec![
"settings",
"keybinds",
- "reload keybinds",
+ "reload config",
"update ready",
"quit"
]
@@ -590,7 +590,7 @@ mod tests {
assert_eq!(
app.state.global_menu_labels(),
- vec!["settings", "keybinds", "reload keybinds", "detach"]
+ vec!["settings", "keybinds", "reload config", "detach"]
);
let menu = app.state.global_menu_rect();
@@ -615,7 +615,7 @@ mod tests {
vec![
"settings",
"keybinds",
- "reload keybinds",
+ "reload config",
"what's new",
"quit"
]
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 14c50f7f..c4806a77 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -247,7 +247,7 @@ impl App {
detach_requested: false,
request_new_workspace: false,
request_new_tab: false,
- request_reload_keybinds: false,
+ request_reload_config: false,
request_clipboard_write: None,
creating_new_tab: false,
requested_new_tab_name: None,
@@ -299,6 +299,7 @@ impl App {
pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes,
accent: crate::config::parse_color(&config.ui.accent),
sound: config.ui.sound.clone(),
+ local_sound_playback: true,
toast_config: config.ui.toast.clone(),
keybinds: config.keybinds(),
spinner_tick: 0,
@@ -415,9 +416,9 @@ impl App {
needs_render = true;
}
- if self.state.request_reload_keybinds {
- self.state.request_reload_keybinds = false;
- self.reload_keybinds();
+ if self.state.request_reload_config {
+ self.state.request_reload_config = false;
+ self.reload_config();
needs_render = true;
}
@@ -531,32 +532,105 @@ impl App {
crate::app::input::open_settings(&mut self.state);
}
- pub(crate) fn reload_keybinds(&mut self) {
+ pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport {
+ self.apply_config_from_disk(true)
+ }
+
+ pub(crate) fn apply_config_from_disk(
+ &mut self,
+ notify_success: bool,
+ ) -> crate::config::ConfigReloadReport {
let previous_toast = self.state.toast.clone();
- match crate::config::load_live_keybinds() {
+ let report = match crate::config::load_live_config() {
+ Ok(loaded) => self.apply_live_config(&loaded.config, notify_success),
+ Err(diagnostics) => {
+ self.state.toast = None;
+ self.state.config_diagnostic =
+ crate::config::config_diagnostic_summary(&diagnostics);
+ self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
+ crate::config::ConfigReloadReport {
+ status: crate::config::ConfigReloadStatus::Failed,
+ diagnostics,
+ }
+ }
+ };
+ self.sync_toast_deadline(previous_toast);
+ report
+ }
+
+ fn apply_live_config(
+ &mut self,
+ config: &crate::config::Config,
+ notify_success: bool,
+ ) -> crate::config::ConfigReloadReport {
+ let mut diagnostics = Vec::new();
+
+ match config.live_keybinds() {
Ok(live) => {
self.state.prefix_code = live.prefix.0;
self.state.prefix_mods = live.prefix.1;
self.state.keybinds = live.keybinds;
- self.state.config_diagnostic = None;
- self.config_diagnostic_deadline = None;
+ }
+ Err(keybind_diagnostics) => {
+ let mut message = keybind_diagnostics.join("; ");
+ if !message.contains("keeping current keybinds") {
+ message.push_str("; keeping current keybinds");
+ }
+ diagnostics.push(message);
+ }
+ }
+
+ diagnostics.extend(config.ui.sound.diagnostics());
+
+ let previous_default_sidebar_width = self.state.default_sidebar_width;
+ self.state.default_sidebar_width = config.ui.sidebar_width;
+ if self.state.sidebar_width == previous_default_sidebar_width {
+ self.state.sidebar_width = config.ui.sidebar_width;
+ }
+ self.state.confirm_close = config.ui.confirm_close;
+ self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
+ self.state.accent = crate::config::parse_color(&config.ui.accent);
+ self.state.sound = config.ui.sound.clone();
+ self.state.toast_config = config.ui.toast.clone();
+ self.state.palette = resolve_palette(config);
+ self.state.theme_name = config
+ .theme
+ .name
+ .clone()
+ .unwrap_or_else(|| "catppuccin".to_string());
+
+ let status = if diagnostics.is_empty() {
+ crate::config::ConfigReloadStatus::Applied
+ } else {
+ crate::config::ConfigReloadStatus::Partial
+ };
+
+ if diagnostics.is_empty() {
+ self.state.config_diagnostic = None;
+ self.config_diagnostic_deadline = None;
+ if notify_success {
self.state.toast = Some(crate::app::state::ToastNotification {
kind: crate::app::state::ToastKind::UpdateInstalled,
- title: "reloaded keybinds".to_string(),
+ title: "reloaded config".to_string(),
context: "using config.toml".to_string(),
});
}
- Err(diagnostics) => {
- let mut message = diagnostics.join("; ");
- if !message.contains("keeping current keybinds") {
- message.push_str("; keeping current keybinds");
- }
- self.state.toast = None;
- self.state.config_diagnostic = Some(message);
- self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
+ } else {
+ self.state.config_diagnostic = crate::config::config_diagnostic_summary(&diagnostics);
+ self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
+ if notify_success {
+ self.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::UpdateInstalled,
+ title: "reloaded config".to_string(),
+ context: "with warnings".to_string(),
+ });
}
}
- self.sync_toast_deadline(previous_toast);
+
+ crate::config::ConfigReloadReport {
+ status,
+ diagnostics,
+ }
}
}
@@ -794,30 +868,35 @@ mod tests {
}
#[test]
- fn reload_keybinds_updates_live_state() {
+ fn reload_config_updates_live_state() {
let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("reload-keybinds-success");
+ let path = temp_config_path("reload-config-success");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n",
+ "[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
- app.reload_keybinds();
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
assert_eq!(app.state.prefix_code, KeyCode::Char('a'));
assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL);
assert_eq!(
app.state.keybinds.new_workspace,
(KeyCode::Char('g'), KeyModifiers::empty())
);
+ assert_eq!(
+ app.state.toast_config.delivery,
+ crate::config::ToastDelivery::Herdr
+ );
assert!(app.state.config_diagnostic.is_none());
let toast = app.state.toast.as_ref().unwrap();
assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled);
- assert_eq!(toast.title, "reloaded keybinds");
+ assert_eq!(toast.title, "reloaded config");
assert_eq!(toast.context, "using config.toml");
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
@@ -825,23 +904,32 @@ mod tests {
}
#[test]
- fn reload_keybinds_keeps_current_state_on_invalid_binding() {
+ fn reload_config_keeps_current_keybinds_on_invalid_binding_but_applies_other_sections() {
let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("reload-keybinds-invalid");
+ let path = temp_config_path("reload-config-invalid-keybind");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- std::fs::write(&path, "[keys]\nnew_workspace = \"wat\"\n").unwrap();
+ std::fs::write(
+ &path,
+ "[keys]\nnew_workspace = \"wat\"\n[ui.toast]\ndelivery = \"terminal\"\n",
+ )
+ .unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
let original_prefix = (app.state.prefix_code, app.state.prefix_mods);
let original_keybinds = app.state.keybinds.new_workspace;
- app.reload_keybinds();
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
assert_eq!(
(app.state.prefix_code, app.state.prefix_mods),
original_prefix
);
assert_eq!(app.state.keybinds.new_workspace, original_keybinds);
+ assert_eq!(
+ app.state.toast_config.delivery,
+ crate::config::ToastDelivery::Terminal
+ );
assert!(app
.state
.config_diagnostic
@@ -850,6 +938,67 @@ mod tests {
message.contains("keys.new_workspace")
&& message.contains("keeping current keybinds")
}));
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn settings_save_toast_delivery_persists_then_applies_live_config() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("settings-save-toast-delivery");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(&path, "onboarding = false\n").unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ assert_eq!(
+ app.state.toast_config.delivery,
+ crate::config::ToastDelivery::Off
+ );
+
+ app.save_toast_delivery(crate::config::ToastDelivery::Terminal);
+
+ assert_eq!(
+ app.state.toast_config.delivery,
+ crate::config::ToastDelivery::Terminal
+ );
+ let content = std::fs::read_to_string(&path).unwrap();
+ assert!(content.contains("delivery = \"terminal\""));
+ assert!(app.state.config_diagnostic.is_none());
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn reload_config_keeps_current_state_on_invalid_toml() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-invalid-toml");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(&path, "[keys\nnew_workspace = \"g\"\n").unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ let original_prefix = (app.state.prefix_code, app.state.prefix_mods);
+ let original_keybinds = app.state.keybinds.new_workspace;
+ let original_toast_delivery = app.state.toast_config.delivery;
+ let report = app.reload_config();
+
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Failed);
+ assert_eq!(
+ (app.state.prefix_code, app.state.prefix_mods),
+ original_prefix
+ );
+ assert_eq!(app.state.keybinds.new_workspace, original_keybinds);
+ assert_eq!(app.state.toast_config.delivery, original_toast_delivery);
+ assert!(app
+ .state
+ .config_diagnostic
+ .as_deref()
+ .is_some_and(|message| {
+ message.contains("config parse error") && message.contains("keeping current config")
+ }));
assert!(app.state.toast.is_none());
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
diff --git a/src/app/state.rs b/src/app/state.rs
index e54b431a..a51a72e7 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -593,7 +593,7 @@ pub struct AppState {
pub detach_requested: bool,
pub request_new_workspace: bool,
pub request_new_tab: bool,
- pub request_reload_keybinds: bool,
+ pub request_reload_config: bool,
/// Set when UI interaction requested a clipboard write that must be
/// handled by the outer App/event loop instead of directly from AppState.
pub request_clipboard_write: Option<Vec<u8>>,
@@ -636,6 +636,7 @@ pub struct AppState {
#[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth
pub accent: Color,
pub sound: SoundConfig,
+ pub local_sound_playback: bool,
pub toast_config: ToastConfig,
pub keybinds: Keybinds,
/// Frame counter for spinner animations (wraps around).
@@ -717,7 +718,7 @@ impl AppState {
detach_requested: false,
request_new_workspace: false,
request_new_tab: false,
- request_reload_keybinds: false,
+ request_reload_config: false,
request_clipboard_write: None,
creating_new_tab: false,
requested_new_tab_name: None,
@@ -767,6 +768,7 @@ impl AppState {
enabled: false,
..SoundConfig::default()
},
+ local_sound_playback: false,
toast_config: ToastConfig::default(),
keybinds: Keybinds {
new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()),
@@ -777,6 +779,8 @@ impl AppState {
close_workspace_label: "shift+d".into(),
detach: None,
detach_label: None,
+ reload_config: None,
+ reload_config_label: None,
previous_workspace: None,
previous_workspace_label: None,
next_workspace: None,
diff --git a/src/cli.rs b/src/cli.rs
index feb24c70..8421863a 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -49,6 +49,7 @@ fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>> {
match subcommand {
"stop" => server_stop(&args[1..]).map(Some),
+ "reload-config" => server_reload_config(&args[1..]).map(Some),
"help" | "--help" | "-h" => {
print_server_help();
Ok(Some(0))
@@ -163,6 +164,18 @@ fn server_stop(args: &[String]) -> std::io::Result<i32> {
send_ok_request(Method::ServerStop(EmptyParams::default()))
}
+fn server_reload_config(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr server reload-config");
+ return Ok(2);
+ }
+
+ print_response(&send_request(&Request {
+ id: "cli:server:reload-config".into(),
+ method: Method::ServerReloadConfig(EmptyParams::default()),
+ })?)
+}
+
fn workspace_list(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr workspace list");
@@ -1041,6 +1054,7 @@ fn print_server_help() {
eprintln!("herdr server commands:");
eprintln!(" herdr server run as headless server");
eprintln!(" herdr server stop stop the running server via the api socket");
+ eprintln!(" herdr server reload-config reload config.toml in the running server");
}
fn print_workspace_help() {
diff --git a/src/config.rs b/src/config.rs
index 71b20f52..e96191bc 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -8,15 +8,15 @@ mod theme;
pub use self::{
io::{
- config_dir, config_path, load_live_keybinds, remove_section_key, upsert_section_bool,
- upsert_section_value,
+ config_diagnostic_summary, config_dir, config_path, load_live_config, remove_section_key,
+ upsert_section_bool, upsert_section_value,
},
keybinds::{
format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind,
Keybinds, LiveKeybindConfig,
},
- model::{Config, ToastConfig, ToastDelivery},
- sound::{AgentSoundSetting, SoundConfig},
+ model::{Config, ConfigReloadReport, ConfigReloadStatus, ToastConfig, ToastDelivery},
+ sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
};
diff --git a/src/config/io.rs b/src/config/io.rs
index 1a3c1920..2774fc93 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
use tracing::warn;
-use super::{model::LoadedConfig, Config, LiveKeybindConfig, CONFIG_PATH_ENV_VAR};
+use super::{model::LoadedConfig, Config, CONFIG_PATH_ENV_VAR};
pub fn app_dir_name() -> &'static str {
if cfg!(debug_assertions) {
@@ -77,23 +77,37 @@ pub fn config_path() -> PathBuf {
config_dir().join("config.toml")
}
-pub fn load_live_keybinds() -> Result<LiveKeybindConfig, Vec<String>> {
+pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option<String> {
+ if diagnostics.is_empty() {
+ None
+ } else if diagnostics.len() == 1 {
+ Some(diagnostics[0].clone())
+ } else {
+ Some(format!(
+ "{} (and {} more)",
+ diagnostics[0],
+ diagnostics.len() - 1
+ ))
+ }
+}
+
+pub fn load_live_config() -> Result<LoadedConfig, Vec<String>> {
let path = config_path();
if !path.exists() {
- return Config::default().live_keybinds();
+ return Ok(LoadedConfig {
+ config: Config::default(),
+ diagnostics: Vec::new(),
+ });
}
- let content = std::fs::read_to_string(&path).map_err(|err| {
- vec![format!(
- "config read error: {err}; keeping current keybinds"
- )]
- })?;
- let config = toml::from_str::<Config>(&content).map_err(|err| {
- vec![format!(
- "config parse error: {err}; keeping current keybinds"
- )]
- })?;
- config.live_keybinds()
+ let content = std::fs::read_to_string(&path)
+ .map_err(|err| vec![format!("config read error: {err}; keeping current config")])?;
+ let config = toml::from_str::<Config>(&content)
+ .map_err(|err| vec![format!("config parse error: {err}; keeping current config")])?;
+ Ok(LoadedConfig {
+ config,
+ diagnostics: Vec::new(),
+ })
}
pub(crate) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String {
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index 07c682ba..4a54dd74 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -65,6 +65,8 @@ pub struct Keybinds {
pub close_workspace_label: String,
pub detach: Option<(KeyCode, KeyModifiers)>,
pub detach_label: Option<String>,
+ pub reload_config: Option<(KeyCode, KeyModifiers)>,
+ pub reload_config_label: Option<String>,
pub previous_workspace: Option<(KeyCode, KeyModifiers)>,
pub previous_workspace_label: Option<String>,
pub next_workspace: Option<(KeyCode, KeyModifiers)>,
@@ -337,6 +339,12 @@ impl Config {
),
optional_binding(
BindingScope::Navigate,
+ "keys.reload_config",
+ &self.keys.reload_config,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
"keys.previous_workspace",
&self.keys.previous_workspace,
&mut diagnostics,
@@ -578,28 +586,30 @@ impl Config {
close_workspace_label: bindings[2].label.clone(),
detach: optional_bindings[0].value,
detach_label: optional_bindings[0].label.clone(),
- previous_workspace: optional_bindings[1].value,
- previous_workspace_label: optional_bindings[1].label.clone(),
- next_workspace: optional_bindings[2].value,
- next_workspace_label: optional_bindings[2].label.clone(),
+ reload_config: optional_bindings[1].value,
+ reload_config_label: optional_bindings[1].label.clone(),
+ previous_workspace: optional_bindings[2].value,
+ previous_workspace_label: optional_bindings[2].label.clone(),
+ next_workspace: optional_bindings[3].value,
+ next_workspace_label: optional_bindings[3].label.clone(),
new_tab: bindings[3].value,
new_tab_label: bindings[3].label.clone(),
- rename_tab: optional_bindings[3].value,
- rename_tab_label: optional_bindings[3].label.clone(),
- previous_tab: optional_bindings[4].value,
- previous_tab_label: optional_bindings[4].label.clone(),
- next_tab: optional_bindings[5].value,
- next_tab_label: optional_bindings[5].label.clone(),
- close_tab: optional_bindings[6].value,
- close_tab_label: optional_bindings[6].label.clone(),
- focus_pane_left: optional_bindings[7].value,
- focus_pane_left_label: optional_bindings[7].label.clone(),
- focus_pane_down: optional_bindings[8].value,
- focus_pane_down_label: optional_bindings[8].label.clone(),
- focus_pane_up: optional_bindings[9].value,
- focus_pane_up_label: optional_bindings[9].label.clone(),
- focus_pane_right: optional_bindings[10].value,
- focus_pane_right_label: optional_bindings[10].label.clone(),
+ rename_tab: optional_bindings[4].value,
+ rename_tab_label: optional_bindings[4].label.clone(),
+ previous_tab: optional_bindings[5].value,
+ previous_tab_label: optional_bindings[5].label.clone(),
+ next_tab: optional_bindings[6].value,
+ next_tab_label: optional_bindings[6].label.clone(),
+ close_tab: optional_bindings[7].value,
+ close_tab_label: optional_bindings[7].label.clone(),
+ focus_pane_left: optional_bindings[8].value,
+ focus_pane_left_label: optional_bindings[8].label.clone(),
+ focus_pane_down: optional_bindings[9].value,
+ focus_pane_down_label: optional_bindings[9].label.clone(),
+ focus_pane_up: optional_bindings[10].value,
+ focus_pane_up_label: optional_bindings[10].label.clone(),
+ focus_pane_right: optional_bindings[11].value,
+ focus_pane_right_label: optional_bindings[11].label.clone(),
split_vertical: bindings[4].value,
split_vertical_label: bindings[4].label.clone(),
split_horizontal: bindings[5].value,
diff --git a/src/config/model.rs b/src/config/model.rs
index e2c39f25..32d60493 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -16,6 +16,20 @@ pub struct ToastConfig {
pub delivery: ToastDelivery,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ConfigReloadStatus {
+ Applied,
+ Partial,
+ Failed,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
+pub struct ConfigReloadReport {
+ pub status: ConfigReloadStatus,
+ pub diagnostics: Vec<String>,
+}
+
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Config {
@@ -45,6 +59,8 @@ pub struct KeysConfig {
pub close_workspace: String,
/// Optional explicit detach shortcut in server/client mode. Unset by default.
pub detach: String,
+ /// Reload config.toml in the running app/server. Unset by default.
+ pub reload_config: String,
/// Select the previous workspace. Unset by default.
pub previous_workspace: String,
/// Select the next workspace. Unset by default.
@@ -116,6 +132,7 @@ impl Default for KeysConfig {
rename_workspace: "shift+n".into(),
close_workspace: "shift+d".into(),
detach: "".into(),
+ reload_config: "".into(),
previous_workspace: "".into(),
next_workspace: "".into(),
new_tab: "c".into(),
diff --git a/src/main.rs b/src/main.rs
index 982e787d..9b66198f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -83,6 +83,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# previous_workspace = "" # optional, unset by default
# next_workspace = "" # optional, unset by default
# detach = "" # optional explicit detach shortcut in server/client mode
+# reload_config = "" # optional shortcut to reload config.toml without restarting
# new_tab = "c"
# rename_tab = "" # optional, unset by default
# previous_tab = "" # optional, unset by default
@@ -190,6 +191,7 @@ fn main() -> io::Result<()> {
println!("Usage: herdr [options]");
println!(" herdr update");
println!(" herdr server stop");
+ println!(" herdr server reload-config");
println!(" herdr workspace <subcommand> ...");
println!(" herdr tab <subcommand> ...");
println!(" herdr pane <subcommand> ...");
@@ -199,6 +201,7 @@ fn main() -> io::Result<()> {
println!("Commands:");
println!(" server Run as headless server (no terminal, persists after client disconnect)");
println!(" server stop Stop the running server via the API socket");
+ println!(" server reload-config Reload config.toml in the running server");
println!(" client Connect to a running server as a thin client");
println!(
" update Download and install the latest version (run outside herdr)"
@@ -322,17 +325,7 @@ fn main() -> io::Result<()> {
}));
let config = &loaded_config.config;
- let config_diagnostic = if loaded_config.diagnostics.is_empty() {
- None
- } else if loaded_config.diagnostics.len() == 1 {
- Some(loaded_config.diagnostics[0].clone())
- } else {
- Some(format!(
- "{} (and {} more)",
- loaded_config.diagnostics[0],
- loaded_config.diagnostics.len() - 1
- ))
- };
+ let config_diagnostic = config::config_diagnostic_summary(&loaded_config.diagnostics);
logging::startup("app");
// Background update check (non-blocking, best-effort)
diff --git a/src/server/headless.rs b/src/server/headless.rs
index d5829f71..c2e814bf 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -512,9 +512,9 @@ impl HeadlessServer {
needs_render = true;
}
- if self.app.state.request_reload_keybinds {
- self.app.state.request_reload_keybinds = false;
- self.app.reload_keybinds();
+ if self.app.state.request_reload_config {
+ self.app.state.request_reload_config = false;
+ self.app.reload_config();
needs_render = true;
}
@@ -800,17 +800,11 @@ impl HeadlessServer {
.unwrap_or(crate::detect::AgentState::Unknown);
// Handle the state change (updates pane state, sets toast on AppState).
- // Note: apply_pane_state_change inside handle_internal_event will try
- // to play sounds, but sound.enabled=false in the headless server, so
- // sound::play is never called. Toast may still be set on AppState if
- // toast_config.enabled is true.
+ // Headless mode disables local sound playback separately from the
+ // sound policy so reloads can keep server-side notification policy live.
self.app.handle_internal_event(ev);
- // Forward sound notification to clients.
- // We check the agent-specific sound setting but NOT sound.enabled,
- // because the server sets enabled=false to prevent local playback —
- // clients should still receive sound notifications and decide
- // locally whether to play them based on their own config.
+ // Forward sound notification to clients when server-side sound policy allows it.
let is_active_tab = self
.app
.state
@@ -821,10 +815,7 @@ impl HeadlessServer {
.is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
});
- if !matches!(
- self.app.state.sound.agents.for_agent(agent_val),
- crate::config::AgentSoundSetting::Off
- ) {
+ if self.app.state.sound.allows(agent_val) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
is_active_tab,
prev_state,
@@ -906,9 +897,10 @@ impl HeadlessServer {
self.app.handle_internal_event(ev);
- // Forward sound notification based on hook state transition.
- // This ensures API-reported state changes (pane.report_agent)
- // produce notifications even before fallback detection confirms.
+ // Forward sound notification based on hook state transition when
+ // server-side sound policy allows it. This ensures API-reported state
+ // changes (pane.report_agent) produce notifications even before
+ // fallback detection confirms.
let is_active_tab = self
.app
.state
@@ -919,10 +911,7 @@ impl HeadlessServer {
.is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
});
- if !matches!(
- self.app.state.sound.agents.for_agent(agent_val),
- crate::config::AgentSoundSetting::Off
- ) {
+ if self.app.state.sound.allows(agent_val) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
is_active_tab,
prev_hook_state,
@@ -1261,8 +1250,8 @@ impl HeadlessServer {
// forward any resulting notifications to connected clients.
// API requests like pane.report_agent trigger handle_internal_event
// internally, which bypasses drain_internal_events_with_forwarding.
- // Since sound.enabled=false in the headless server, sounds would be
- // silently dropped; toasts may be set but not forwarded.
+ // Headless mode disables local sound playback, so sound notifications
+ // need to be forwarded to clients here; toasts may be set but not forwarded.
//
// Note: pane.report_agent sets hook_authority on the pane, but the
// effective state may not change until the fallback detector confirms
@@ -1411,13 +1400,9 @@ impl HeadlessServer {
}
}
- // Check agent-specific sound setting but NOT sound.enabled,
- // because the server sets enabled=false to prevent local playback.
- // Clients decide locally whether to play sounds.
- if !matches!(
- self.app.state.sound.agents.for_agent(agent),
- crate::config::AgentSoundSetting::Off
- ) {
+ // Forward sound notification when server-side sound policy allows it.
+ // Clients still decide locally whether they can execute the side effect.
+ if self.app.state.sound.allows(agent) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
is_active_tab,
prev_state,
@@ -1957,7 +1942,7 @@ pub fn run_server() -> io::Result<()> {
let mut app = app::App::new(
&loaded_config.config,
no_session,
- None, // config_diagnostic
+ config::config_diagnostic_summary(&loaded_config.diagnostics),
None, // startup_release_notes
api_rx,
event_hub,
@@ -1966,7 +1951,7 @@ pub fn run_server() -> io::Result<()> {
// The server runs headless — disable local sound playback.
// Sound notifications are forwarded to connected clients as
// ServerMessage::Notify instead of played locally.
- app.state.sound.enabled = false;
+ app.state.local_sound_playback = false;
// Create the headless server.
let mut server = match HeadlessServer::new(app) {
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index ccff95ab..2bb05c50 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -32,6 +32,10 @@ pub(super) fn keybind_help_groups(
"navigate mode",
),
("prefix + ?".to_string(), "keybinds"),
+ (
+ optional_keybind_label(&kb.reload_config_label),
+ "reload config",
+ ),
],
));