feat: add live config reload

This commit is contained in:
Ogulcan Celik 2026-04-26 02:07:33 +03:00
parent ef4182efd9
commit 3d1ef155bf
23 changed files with 588 additions and 140 deletions

View File

@ -2,6 +2,12 @@
## Unreleased
### Added
- Config can now be reloaded in the running app/server from the global menu or with `herdr server reload-config`, applying safe live settings without restarting the persistent server.
### Fixed
- Persistent server startup now surfaces config diagnostics in attached clients instead of silently hiding parse or validation errors.
## [0.5.1] - 2026-04-25
### Added

View File

@ -14,6 +14,35 @@ herdr --default-config
if a config value is invalid, or two navigate actions use the same keybinding, herdr falls back to a safe default and shows a startup warning in the UI.
## live reload
After editing `config.toml`, reload the running app without restarting the persistent server:
```bash
herdr server reload-config
```
You can also use the global menu inside herdr and choose `reload config`.
Reload is server-owned. In persistent mode the CLI sends a request to the running server, and the server reads, parses, validates, and applies `config.toml`.
Reloadable now:
- keybindings and prefix
- theme, custom theme colors, and legacy `ui.accent`
- `ui.confirm_close`
- `ui.toast.delivery`
- server-side `ui.sound` policy
- `advanced.scrollback_limit_bytes` for panes created after reload
- `ui.sidebar_width` as the default width
Startup-only or special-case:
- `onboarding` does not reopen onboarding during reload
- `advanced.allow_nested` is checked before launch and needs a restart
- existing pane scrollback buffers are not resized during reload
- already-attached thin clients may need to reconnect before client-local sound file/path changes are picked up
If the TOML cannot be read or parsed, reload applies nothing and keeps the current running state. If keybindings are invalid, herdr keeps the current keybindings while applying other valid reloadable settings where possible.
## onboarding
```toml
@ -54,6 +83,7 @@ prefix = "ctrl+b"
new_workspace = "n"
rename_workspace = "shift+n"
close_workspace = "shift+d"
reload_config = "" # optional, unset by default
new_tab = "c"
split_vertical = "v"
split_horizontal = "-"
@ -80,6 +110,7 @@ focus_pane_right = "alt+l"
| `rename_workspace` | `shift+n` | rename selected workspace |
| `close_workspace` | `shift+d` | close selected workspace |
| `detach` | unset | optional explicit detach shortcut in the persistent session |
| `reload_config` | unset | reload `config.toml` in the running app/server |
| `previous_workspace` | unset | switch to the previous workspace directly from terminal mode |
| `next_workspace` | unset | switch to the next workspace directly from terminal mode |
| `new_tab` | `c` | create a new tab |

View File

@ -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",

View File

@ -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 {},
}
@ -765,6 +771,19 @@ mod tests {
assert_eq!(restored, request);
}
#[test]
fn request_round_trips_for_server_reload_config() {
let request = Request {
id: "req_reload".into(),
method: Method::ServerReloadConfig(EmptyParams::default()),
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["method"], "server.reload_config");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, request);
}
#[test]
fn unknown_method_is_rejected() {
let json = r#"{"id":"req_1","method":"nope","params":{}}"#;

View File

@ -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,

View File

@ -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 {

View File

@ -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);
}
}
}

View File

@ -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),

View File

@ -350,6 +350,7 @@ pub(crate) enum NavigateAction {
Fullscreen,
EnterResizeMode,
ToggleSidebar,
ReloadConfig,
Detach,
}
@ -421,6 +422,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA
if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
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);
@ -582,6 +593,21 @@ mod tests {
assert_eq!(state.mode, Mode::Resize);
}
#[test]
fn custom_reload_config_key_requests_reload_and_exits_navigate() {
let mut state = state_with_workspaces(&["test"]);
state.keybinds.reload_config = Some((KeyCode::Char('g'), KeyModifiers::empty()));
state.keybinds.reload_config_label = Some("g".into());
handle_navigate_key(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
);
assert!(state.request_reload_config);
assert_eq!(state.mode, Mode::Terminal);
}
#[test]
fn movement_action_stays_in_navigate_mode() {
let mut state = state_with_workspaces(&["a", "b"]);

View File

@ -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);
}

View File

@ -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"
]

View File

@ -247,7 +247,7 @@ impl App {
detach_requested: false,
request_new_workspace: false,
request_new_tab: false,
request_reload_keybinds: false,
request_reload_config: false,
request_clipboard_write: None,
creating_new_tab: false,
requested_new_tab_name: None,
@ -299,6 +299,7 @@ impl App {
pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes,
accent: crate::config::parse_color(&config.ui.accent),
sound: config.ui.sound.clone(),
local_sound_playback: true,
toast_config: config.ui.toast.clone(),
keybinds: config.keybinds(),
spinner_tick: 0,
@ -415,9 +416,9 @@ impl App {
needs_render = true;
}
if self.state.request_reload_keybinds {
self.state.request_reload_keybinds = false;
self.reload_keybinds();
if self.state.request_reload_config {
self.state.request_reload_config = false;
self.reload_config();
needs_render = true;
}
@ -531,32 +532,105 @@ impl App {
crate::app::input::open_settings(&mut self.state);
}
pub(crate) fn reload_keybinds(&mut self) {
pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport {
self.apply_config_from_disk(true)
}
pub(crate) fn apply_config_from_disk(
&mut self,
notify_success: bool,
) -> crate::config::ConfigReloadReport {
let previous_toast = self.state.toast.clone();
match crate::config::load_live_keybinds() {
let report = match crate::config::load_live_config() {
Ok(loaded) => self.apply_live_config(&loaded.config, notify_success),
Err(diagnostics) => {
self.state.toast = None;
self.state.config_diagnostic =
crate::config::config_diagnostic_summary(&diagnostics);
self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
crate::config::ConfigReloadReport {
status: crate::config::ConfigReloadStatus::Failed,
diagnostics,
}
}
};
self.sync_toast_deadline(previous_toast);
report
}
fn apply_live_config(
&mut self,
config: &crate::config::Config,
notify_success: bool,
) -> crate::config::ConfigReloadReport {
let mut diagnostics = Vec::new();
match config.live_keybinds() {
Ok(live) => {
self.state.prefix_code = live.prefix.0;
self.state.prefix_mods = live.prefix.1;
self.state.keybinds = live.keybinds;
self.state.config_diagnostic = None;
self.config_diagnostic_deadline = None;
self.state.toast = Some(crate::app::state::ToastNotification {
kind: crate::app::state::ToastKind::UpdateInstalled,
title: "reloaded keybinds".to_string(),
context: "using config.toml".to_string(),
});
}
Err(diagnostics) => {
let mut message = diagnostics.join("; ");
Err(keybind_diagnostics) => {
let mut message = keybind_diagnostics.join("; ");
if !message.contains("keeping current keybinds") {
message.push_str("; keeping current keybinds");
}
self.state.toast = None;
self.state.config_diagnostic = Some(message);
self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
diagnostics.push(message);
}
}
self.sync_toast_deadline(previous_toast);
diagnostics.extend(config.ui.sound.diagnostics());
let previous_default_sidebar_width = self.state.default_sidebar_width;
self.state.default_sidebar_width = config.ui.sidebar_width;
if self.state.sidebar_width == previous_default_sidebar_width {
self.state.sidebar_width = config.ui.sidebar_width;
}
self.state.confirm_close = config.ui.confirm_close;
self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
self.state.accent = crate::config::parse_color(&config.ui.accent);
self.state.sound = config.ui.sound.clone();
self.state.toast_config = config.ui.toast.clone();
self.state.palette = resolve_palette(config);
self.state.theme_name = config
.theme
.name
.clone()
.unwrap_or_else(|| "catppuccin".to_string());
let status = if diagnostics.is_empty() {
crate::config::ConfigReloadStatus::Applied
} else {
crate::config::ConfigReloadStatus::Partial
};
if diagnostics.is_empty() {
self.state.config_diagnostic = None;
self.config_diagnostic_deadline = None;
if notify_success {
self.state.toast = Some(crate::app::state::ToastNotification {
kind: crate::app::state::ToastKind::UpdateInstalled,
title: "reloaded config".to_string(),
context: "using config.toml".to_string(),
});
}
} else {
self.state.config_diagnostic = crate::config::config_diagnostic_summary(&diagnostics);
self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
if notify_success {
self.state.toast = Some(crate::app::state::ToastNotification {
kind: crate::app::state::ToastKind::UpdateInstalled,
title: "reloaded config".to_string(),
context: "with warnings".to_string(),
});
}
}
crate::config::ConfigReloadReport {
status,
diagnostics,
}
}
}
@ -794,30 +868,35 @@ mod tests {
}
#[test]
fn reload_keybinds_updates_live_state() {
fn reload_config_updates_live_state() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-keybinds-success");
let path = temp_config_path("reload-config-success");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n",
"[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
app.reload_keybinds();
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
assert_eq!(app.state.prefix_code, KeyCode::Char('a'));
assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL);
assert_eq!(
app.state.keybinds.new_workspace,
(KeyCode::Char('g'), KeyModifiers::empty())
);
assert_eq!(
app.state.toast_config.delivery,
crate::config::ToastDelivery::Herdr
);
assert!(app.state.config_diagnostic.is_none());
let toast = app.state.toast.as_ref().unwrap();
assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled);
assert_eq!(toast.title, "reloaded keybinds");
assert_eq!(toast.title, "reloaded config");
assert_eq!(toast.context, "using config.toml");
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
@ -825,23 +904,32 @@ mod tests {
}
#[test]
fn reload_keybinds_keeps_current_state_on_invalid_binding() {
fn reload_config_keeps_current_keybinds_on_invalid_binding_but_applies_other_sections() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-keybinds-invalid");
let path = temp_config_path("reload-config-invalid-keybind");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "[keys]\nnew_workspace = \"wat\"\n").unwrap();
std::fs::write(
&path,
"[keys]\nnew_workspace = \"wat\"\n[ui.toast]\ndelivery = \"terminal\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
let original_prefix = (app.state.prefix_code, app.state.prefix_mods);
let original_keybinds = app.state.keybinds.new_workspace;
app.reload_keybinds();
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
assert_eq!(
(app.state.prefix_code, app.state.prefix_mods),
original_prefix
);
assert_eq!(app.state.keybinds.new_workspace, original_keybinds);
assert_eq!(
app.state.toast_config.delivery,
crate::config::ToastDelivery::Terminal
);
assert!(app
.state
.config_diagnostic
@ -850,6 +938,67 @@ mod tests {
message.contains("keys.new_workspace")
&& message.contains("keeping current keybinds")
}));
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn settings_save_toast_delivery_persists_then_applies_live_config() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("settings-save-toast-delivery");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "onboarding = false\n").unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
assert_eq!(
app.state.toast_config.delivery,
crate::config::ToastDelivery::Off
);
app.save_toast_delivery(crate::config::ToastDelivery::Terminal);
assert_eq!(
app.state.toast_config.delivery,
crate::config::ToastDelivery::Terminal
);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("delivery = \"terminal\""));
assert!(app.state.config_diagnostic.is_none());
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn reload_config_keeps_current_state_on_invalid_toml() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-config-invalid-toml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "[keys\nnew_workspace = \"g\"\n").unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
let original_prefix = (app.state.prefix_code, app.state.prefix_mods);
let original_keybinds = app.state.keybinds.new_workspace;
let original_toast_delivery = app.state.toast_config.delivery;
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Failed);
assert_eq!(
(app.state.prefix_code, app.state.prefix_mods),
original_prefix
);
assert_eq!(app.state.keybinds.new_workspace, original_keybinds);
assert_eq!(app.state.toast_config.delivery, original_toast_delivery);
assert!(app
.state
.config_diagnostic
.as_deref()
.is_some_and(|message| {
message.contains("config parse error") && message.contains("keeping current config")
}));
assert!(app.state.toast.is_none());
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);

View File

@ -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,

View File

@ -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() {

View File

@ -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},
};

View File

@ -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 {

View File

@ -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)>,
@ -335,6 +337,12 @@ impl Config {
&self.keys.detach,
&mut diagnostics,
),
optional_binding(
BindingScope::Navigate,
"keys.reload_config",
&self.keys.reload_config,
&mut diagnostics,
),
optional_binding(
BindingScope::Navigate,
"keys.previous_workspace",
@ -578,28 +586,30 @@ impl Config {
close_workspace_label: bindings[2].label.clone(),
detach: optional_bindings[0].value,
detach_label: optional_bindings[0].label.clone(),
previous_workspace: optional_bindings[1].value,
previous_workspace_label: optional_bindings[1].label.clone(),
next_workspace: optional_bindings[2].value,
next_workspace_label: optional_bindings[2].label.clone(),
reload_config: optional_bindings[1].value,
reload_config_label: optional_bindings[1].label.clone(),
previous_workspace: optional_bindings[2].value,
previous_workspace_label: optional_bindings[2].label.clone(),
next_workspace: optional_bindings[3].value,
next_workspace_label: optional_bindings[3].label.clone(),
new_tab: bindings[3].value,
new_tab_label: bindings[3].label.clone(),
rename_tab: optional_bindings[3].value,
rename_tab_label: optional_bindings[3].label.clone(),
previous_tab: optional_bindings[4].value,
previous_tab_label: optional_bindings[4].label.clone(),
next_tab: optional_bindings[5].value,
next_tab_label: optional_bindings[5].label.clone(),
close_tab: optional_bindings[6].value,
close_tab_label: optional_bindings[6].label.clone(),
focus_pane_left: optional_bindings[7].value,
focus_pane_left_label: optional_bindings[7].label.clone(),
focus_pane_down: optional_bindings[8].value,
focus_pane_down_label: optional_bindings[8].label.clone(),
focus_pane_up: optional_bindings[9].value,
focus_pane_up_label: optional_bindings[9].label.clone(),
focus_pane_right: optional_bindings[10].value,
focus_pane_right_label: optional_bindings[10].label.clone(),
rename_tab: optional_bindings[4].value,
rename_tab_label: optional_bindings[4].label.clone(),
previous_tab: optional_bindings[5].value,
previous_tab_label: optional_bindings[5].label.clone(),
next_tab: optional_bindings[6].value,
next_tab_label: optional_bindings[6].label.clone(),
close_tab: optional_bindings[7].value,
close_tab_label: optional_bindings[7].label.clone(),
focus_pane_left: optional_bindings[8].value,
focus_pane_left_label: optional_bindings[8].label.clone(),
focus_pane_down: optional_bindings[9].value,
focus_pane_down_label: optional_bindings[9].label.clone(),
focus_pane_up: optional_bindings[10].value,
focus_pane_up_label: optional_bindings[10].label.clone(),
focus_pane_right: optional_bindings[11].value,
focus_pane_right_label: optional_bindings[11].label.clone(),
split_vertical: bindings[4].value,
split_vertical_label: bindings[4].label.clone(),
split_horizontal: bindings[5].value,

View File

@ -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(),

View File

@ -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)

View File

@ -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) {

View File

@ -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",
),
],
));

View File

@ -467,6 +467,16 @@ fn workspace_and_pane_management_commands_work() {
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let reloaded = run_cli(&socket_path, &["server", "reload-config"]);
assert!(
reloaded.status.success(),
"stderr: {}",
String::from_utf8_lossy(&reloaded.stderr)
);
let reload_json: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap();
assert_eq!(reload_json["result"]["type"], "config_reload");
assert_eq!(reload_json["result"]["status"], "applied");
let listed = run_cli(&socket_path, &["workspace", "list"]);
assert!(listed.status.success());
let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();

View File

@ -11,6 +11,7 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use serde::Deserialize;
use support::{
cleanup_test_base, client_handshake, encode_varint_u16, encode_varint_u32, frame_message,
read_server_message, register_runtime_dir, register_spawned_herdr_pid,
@ -157,6 +158,71 @@ fn ping_socket(socket_path: &PathBuf) -> String {
response.trim().to_string()
}
#[derive(Debug, Deserialize)]
struct FrameWire {
cells: Vec<CellWire>,
width: u16,
height: u16,
cursor: Option<CursorWire>,
}
#[derive(Debug, Deserialize)]
struct CellWire {
symbol: String,
fg: u32,
bg: u32,
modifier: u16,
skip: bool,
}
#[derive(Debug, Deserialize)]
struct CursorWire {
x: u16,
y: u16,
visible: bool,
}
fn decode_frame_payload(payload: &[u8]) -> std::io::Result<FrameWire> {
bincode::serde::decode_from_slice(payload, bincode::config::standard())
.map(|(frame, consumed)| (frame, consumed))
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string()))
.and_then(|(frame, consumed): (FrameWire, usize)| {
if consumed != payload.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"frame payload had trailing bytes: consumed={}, len={}",
consumed,
payload.len()
),
));
}
Ok(frame)
})
}
fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool {
if frame.cells.is_empty() {
return false;
}
let width = frame.width.max(1) as usize;
let mut text = String::new();
for row in frame.cells.chunks(width) {
for cell in row {
let _ = (cell.fg, cell.bg, cell.modifier, cell.skip);
text.push_str(&cell.symbol);
}
text.push('\n');
}
let _ = frame.height;
if let Some(cursor) = frame.cursor.as_ref() {
let _ = (cursor.x, cursor.y, cursor.visible);
}
text.contains(needle)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@ -206,6 +272,91 @@ fn client_connects_and_receives_frame() {
cleanup_spawned_herdr(spawned, base);
}
#[test]
fn client_sees_headless_startup_config_diagnostic() {
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let api_socket = runtime_dir.join("herdr.sock");
let client_socket = runtime_dir.join("herdr-client.sock");
let app_dir = if cfg!(debug_assertions) {
"herdr-dev"
} else {
"herdr"
};
fs::create_dir_all(config_home.join(app_dir)).unwrap();
fs::write(
config_home.join(app_dir).join("config.toml"),
"[keys\nprefix = \"ctrl+a\"\n",
)
.unwrap();
fs::create_dir_all(&runtime_dir).unwrap();
register_runtime_dir(&runtime_dir);
let pair = native_pty_system()
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
cmd.arg("server");
cmd.env("XDG_CONFIG_HOME", &config_home);
cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
cmd.env("HERDR_SOCKET_PATH", &api_socket);
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
cmd.env("SHELL", "/bin/sh");
cmd.env_remove("HERDR_ENV");
let child = pair.slave.spawn_command(cmd).unwrap();
register_spawned_herdr_pid(child.process_id());
drop(pair.slave);
let spawned = SpawnedHerdr {
_master: pair.master,
child,
};
wait_for_socket(&api_socket, Duration::from_secs(10));
wait_for_file(&client_socket, Duration::from_secs(10));
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
assert_eq!(version, 1);
assert!(error.is_none(), "{:?}", error);
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut found_diagnostic = false;
while Instant::now() < deadline {
match read_server_message(&mut stream) {
Ok((1, payload)) => {
let frame = decode_frame_payload(&payload).expect("decode frame");
if frame_contains_text(&frame, "config parse error") {
found_diagnostic = true;
break;
}
}
Ok(_) => {}
Err(_) => break,
}
}
assert!(
found_diagnostic,
"attached client should see startup config parse diagnostic"
);
cleanup_spawned_herdr(spawned, base);
}
#[test]
fn client_input_forwarded_to_pane() {
// Stdin input is forwarded to server as ClientMessage::Input.