aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/api.rs12
-rw-r--r--src/app/config_io.rs1
-rw-r--r--src/app/input/settings.rs8
-rw-r--r--src/client/mod.rs46
-rw-r--r--src/config/model.rs11
-rw-r--r--src/main.rs1
-rw-r--r--src/platform/fallback.rs5
-rw-r--r--src/platform/linux.rs64
-rw-r--r--src/platform/macos.rs61
-rw-r--r--src/server/headless.rs72
-rw-r--r--src/server/protocol.rs26
-rw-r--r--src/ui/settings.rs1
12 files changed, 279 insertions, 29 deletions
diff --git a/src/app/api.rs b/src/app/api.rs
index 46429ffe..1fb11118 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -84,11 +84,17 @@ impl App {
if self.local_terminal_notifications
&& matches!(
self.state.toast_config.delivery,
- crate::config::ToastDelivery::Terminal
+ crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System
)
{
+ let notify = match self.state.toast_config.delivery {
+ crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification,
+ crate::config::ToastDelivery::System => crate::platform::show_desktop_notification,
+ _ => unreachable!("toast delivery was checked above"),
+ };
+
if let Some(version) = update_ready_version {
- let _ = crate::terminal_notify::show_notification(
+ let _ = notify(
&format!("v{version} available"),
Some("detach, then run `herdr update`"),
);
@@ -127,7 +133,7 @@ impl App {
ToastKind::Finished => "finished",
ToastKind::UpdateInstalled => "updated",
};
- let _ = crate::terminal_notify::show_notification(
+ let _ = notify(
&format!("{} {}", agent_label, event_text),
Some(&crate::app::actions::notification_context(
ws,
diff --git a/src/app/config_io.rs b/src/app/config_io.rs
index 0bf04831..36f44b9a 100644
--- a/src/app/config_io.rs
+++ b/src/app/config_io.rs
@@ -62,6 +62,7 @@ impl App {
crate::config::ToastDelivery::Off => "\"off\"",
crate::config::ToastDelivery::Herdr => "\"herdr\"",
crate::config::ToastDelivery::Terminal => "\"terminal\"",
+ crate::config::ToastDelivery::System => "\"system\"",
};
if self.update_config_file("toast setting", |content| {
let content =
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
index 9e89e515..79fdf5d8 100644
--- a/src/app/input/settings.rs
+++ b/src/app/input/settings.rs
@@ -51,6 +51,7 @@ fn toast_delivery_index(delivery: ToastDelivery) -> usize {
ToastDelivery::Off => 0,
ToastDelivery::Herdr => 1,
ToastDelivery::Terminal => 2,
+ ToastDelivery::System => 3,
}
}
@@ -58,7 +59,8 @@ fn toast_delivery_for_index(idx: usize) -> ToastDelivery {
match idx {
0 => ToastDelivery::Off,
1 => ToastDelivery::Herdr,
- _ => ToastDelivery::Terminal,
+ 2 => ToastDelivery::Terminal,
+ _ => ToastDelivery::System,
}
}
@@ -151,7 +153,7 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
},
SettingsSection::Toast => match key.code {
KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(),
- KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(3),
+ KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(4),
KeyCode::Enter | KeyCode::Char(' ') => {
let delivery = toast_delivery_for_index(state.settings.list.selected);
return Some(SettingsAction::SaveToastDelivery(delivery));
@@ -274,7 +276,7 @@ impl AppState {
}
SettingsSection::Toast => {
let list_y = area.y + 3;
- if row >= list_y && row < list_y + 6 {
+ if row >= list_y && row < list_y + 8 {
Some(((row - list_y) / 2) as usize)
} else {
None
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 00e1b138..50a54ef3 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -672,19 +672,21 @@ fn reload_local_sound_config(sound_config: &mut crate::config::SoundConfig) {
}
fn handle_notify(kind: NotifyKind, message: &str, sound_config: &crate::config::SoundConfig) {
- handle_notify_with_terminal_notifier(
+ handle_notify_with_notifiers(
kind,
message,
sound_config,
crate::terminal_notify::show_notification,
+ crate::platform::show_desktop_notification,
);
}
-fn handle_notify_with_terminal_notifier(
+fn handle_notify_with_notifiers(
kind: NotifyKind,
message: &str,
sound_config: &crate::config::SoundConfig,
mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>,
+ mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>,
) {
match kind {
NotifyKind::Sound => {
@@ -700,12 +702,25 @@ fn handle_notify_with_terminal_notifier(
}
}
NotifyKind::Toast => {
- debug!(message = message, "received toast notification from server");
+ debug!(
+ message = message,
+ "received terminal toast notification from server"
+ );
let (title, body) = crate::terminal_notify::split_message(message);
if let Err(err) = show_terminal_notification(title, body) {
warn!(err = %err, "failed to emit terminal notification");
}
}
+ NotifyKind::SystemToast => {
+ debug!(
+ message = message,
+ "received system toast notification from server"
+ );
+ let (title, body) = crate::terminal_notify::split_message(message);
+ if let Err(err) = show_system_notification(title, body) {
+ warn!(err = %err, "failed to emit system notification");
+ }
+ }
}
}
@@ -1066,7 +1081,7 @@ mod tests {
let sound_config = crate::config::SoundConfig::default();
let mut emitted = None;
- handle_notify_with_terminal_notifier(
+ handle_notify_with_notifiers(
NotifyKind::Toast,
"pi finished: workspace 1",
&sound_config,
@@ -1074,6 +1089,29 @@ mod tests {
emitted = Some((title.to_string(), body.map(str::to_string)));
Ok(true)
},
+ |_, _| Ok(false),
+ );
+
+ assert_eq!(
+ emitted,
+ Some(("pi finished".to_string(), Some("workspace 1".to_string())))
+ );
+ }
+
+ #[test]
+ fn system_toast_notify_from_server_uses_system_notifier() {
+ let sound_config = crate::config::SoundConfig::default();
+ let mut emitted = None;
+
+ handle_notify_with_notifiers(
+ NotifyKind::SystemToast,
+ "pi finished: workspace 1",
+ &sound_config,
+ |_, _| Ok(false),
+ |title, body| {
+ emitted = Some((title.to_string(), body.map(str::to_string)));
+ Ok(true)
+ },
);
assert_eq!(
diff --git a/src/config/model.rs b/src/config/model.rs
index a576418b..a8485fb0 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -9,6 +9,7 @@ pub enum ToastDelivery {
Off,
Herdr,
Terminal,
+ System,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
@@ -299,6 +300,16 @@ delivery = "terminal"
}
#[test]
+ fn toast_config_parses_system_delivery() {
+ let toml = r#"
+[ui.toast]
+delivery = "system"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.ui.toast.delivery, ToastDelivery::System);
+ }
+
+ #[test]
fn toast_config_legacy_enabled_true_maps_to_herdr() {
let toml = r#"
[ui.toast]
diff --git a/src/main.rs b/src/main.rs
index 8a43746d..92e6220d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -143,6 +143,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# off = disable pop-up notifications
# herdr = show top-right in-app toasts
# terminal = ask the outer terminal to show a desktop notification
+# system = ask the OS notification service directly
# delivery = "off"
# Play sounds when agents change state in background workspaces
diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs
index 7addbc5d..bf3ac42b 100644
--- a/src/platform/fallback.rs
+++ b/src/platform/fallback.rs
@@ -34,3 +34,8 @@ pub fn process_exists(_pid: u32) -> bool {
pub fn write_clipboard(_bytes: &[u8]) -> bool {
false
}
+
+/// Unsupported platform stub.
+pub fn show_desktop_notification(_title: &str, _body: Option<&str>) -> std::io::Result<bool> {
+ Ok(false)
+}
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 13aa6ec8..6823cca8 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -155,6 +155,43 @@ pub fn write_clipboard(bytes: &[u8]) -> bool {
false
}
+/// Show a native desktop notification through libnotify's command-line helper.
+pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> {
+ show_desktop_notification_with_command(title, body, |program| Command::new(program))
+}
+
+fn show_desktop_notification_with_command(
+ title: &str,
+ body: Option<&str>,
+ mut command: impl FnMut(&str) -> Command,
+) -> std::io::Result<bool> {
+ if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() {
+ return Ok(false);
+ }
+
+ let mut cmd = command("notify-send");
+ cmd.arg("--").arg(title);
+ if let Some(body) = body.filter(|body| !body.is_empty()) {
+ cmd.arg(body);
+ }
+ run_notification_command(cmd)
+}
+
+fn run_notification_command(mut command: Command) -> std::io::Result<bool> {
+ let status = match command
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ {
+ Ok(status) => status,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
+ Err(err) => return Err(err),
+ };
+
+ Ok(status.success())
+}
+
fn clipboard_commands() -> Vec<ClipboardCommand> {
let mut commands = Vec::new();
@@ -248,4 +285,31 @@ mod tests {
assert_eq!(commands[0].program, "xclip");
assert_eq!(commands[1].program, "xsel");
}
+
+ #[test]
+ fn desktop_notification_separates_option_like_titles() {
+ let _guard = env_lock().lock().unwrap();
+ unsafe {
+ std::env::remove_var("WAYLAND_DISPLAY");
+ std::env::set_var("DISPLAY", ":0");
+ }
+
+ let path =
+ std::env::temp_dir().join(format!("herdr-notify-send-args-{}", std::process::id()));
+ let script = "printf '%s\\n' \"$@\" > \"$HERDR_NOTIFY_ARGS\"";
+ let shown = show_desktop_notification_with_command("-danger", Some("body"), |_| {
+ let mut cmd = Command::new("sh");
+ cmd.arg("-c")
+ .arg(script)
+ .arg("notify-send")
+ .env("HERDR_NOTIFY_ARGS", &path);
+ cmd
+ })
+ .expect("notification command should run");
+
+ assert!(shown);
+ let args = std::fs::read_to_string(&path).expect("args file");
+ let _ = std::fs::remove_file(&path);
+ assert_eq!(args, "--\n-danger\nbody\n");
+ }
}
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index 987f5f54..3debc244 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -201,6 +201,43 @@ pub fn write_clipboard(bytes: &[u8]) -> bool {
)
}
+/// Show a native macOS notification through AppleScript.
+pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> {
+ show_desktop_notification_with_command(title, body, |program| Command::new(program))
+}
+
+fn show_desktop_notification_with_command(
+ title: &str,
+ body: Option<&str>,
+ mut command: impl FnMut(&str) -> Command,
+) -> std::io::Result<bool> {
+ let mut cmd = command("/usr/bin/osascript");
+ cmd.arg("-e")
+ .arg("on run argv")
+ .arg("-e")
+ .arg("display notification (item 2 of argv) with title (item 1 of argv)")
+ .arg("-e")
+ .arg("end run")
+ .arg(title)
+ .arg(body.unwrap_or_default());
+ run_notification_command(cmd)
+}
+
+fn run_notification_command(mut command: Command) -> std::io::Result<bool> {
+ let status = match command
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ {
+ Ok(status) => status,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
+ Err(err) => return Err(err),
+ };
+
+ Ok(status.success())
+}
+
fn run_clipboard_command(command: &ClipboardCommand, bytes: &[u8]) -> bool {
let mut child = match Command::new(command.program)
.args(command.args)
@@ -469,4 +506,28 @@ mod tests {
assert_eq!(argv.join(" "), "node /Users/can/.local/bin/pi");
assert!(!argv.join(" ").contains("codex.system"));
}
+
+ #[test]
+ fn desktop_notification_uses_osascript_argv() {
+ let path =
+ std::env::temp_dir().join(format!("herdr-osascript-args-{}", std::process::id()));
+ let script = "printf '%s\\n' \"$@\" > \"$HERDR_NOTIFY_ARGS\"";
+ let shown = show_desktop_notification_with_command("title", Some("body"), |_| {
+ let mut cmd = Command::new("sh");
+ cmd.arg("-c")
+ .arg(script)
+ .arg("osascript")
+ .env("HERDR_NOTIFY_ARGS", &path);
+ cmd
+ })
+ .expect("notification command should run");
+
+ assert!(shown);
+ let args = std::fs::read_to_string(&path).expect("args file");
+ let _ = std::fs::remove_file(&path);
+ assert_eq!(
+ args,
+ "-e\non run argv\n-e\ndisplay notification (item 2 of argv) with title (item 1 of argv)\n-e\nend run\ntitle\nbody\n"
+ );
+ }
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 11c486cb..cc57c1f5 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -87,7 +87,15 @@ const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5);
const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250);
fn should_forward_toast_to_clients(delivery: config::ToastDelivery) -> bool {
- matches!(delivery, config::ToastDelivery::Terminal)
+ toast_notify_kind(delivery).is_some()
+}
+
+fn toast_notify_kind(delivery: config::ToastDelivery) -> Option<protocol::NotifyKind> {
+ match delivery {
+ config::ToastDelivery::Terminal => Some(protocol::NotifyKind::Toast),
+ config::ToastDelivery::System => Some(protocol::NotifyKind::SystemToast),
+ config::ToastDelivery::Off | config::ToastDelivery::Herdr => None,
+ }
}
fn toast_event_text(kind: app::state::ToastKind) -> &'static str {
@@ -847,7 +855,8 @@ impl HeadlessServer {
if let Some(msg) = toast_msg {
self.send_to_foreground_client(ServerMessage::Notify {
- kind: protocol::NotifyKind::Toast,
+ kind: toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
message: msg,
});
}
@@ -930,7 +939,8 @@ impl HeadlessServer {
if let Some(msg) = toast_msg {
self.send_to_foreground_client(ServerMessage::Notify {
- kind: protocol::NotifyKind::Toast,
+ kind: toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
message: msg,
});
}
@@ -962,7 +972,8 @@ impl HeadlessServer {
if let Some(msg) = toast_msg {
self.send_to_foreground_client(ServerMessage::Notify {
- kind: protocol::NotifyKind::Toast,
+ kind: toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
message: msg,
});
}
@@ -991,7 +1002,7 @@ impl HeadlessServer {
/// - Detect when a sound would be played and forward as
/// `ServerMessage::Notify { kind: Sound }` to the foreground client.
/// - Detect when a toast is set on AppState and forward as
- /// `ServerMessage::Notify { kind: Toast }` to the foreground client.
+ /// `ServerMessage::Notify` to the foreground client for terminal/system delivery.
fn drain_internal_events_with_forwarding(&mut self) -> bool {
let mut changed = false;
while let Ok(ev) = self.app.event_rx.try_recv() {
@@ -1322,9 +1333,9 @@ impl HeadlessServer {
let response = self.app.handle_api_request(msg.request);
let _ = msg.respond_to.send(response);
- // Forward new toast state only when terminal delivery is selected.
+ // Forward new toast state only when a client-local delivery mode is selected.
// Herdr delivery renders the toast in-frame and must not ask clients to
- // show a terminal/desktop notification.
+ // show a terminal or system notification.
let toast_after = self.app.state.toast.clone();
let forwarded_toast_from_state =
if should_forward_toast_to_clients(self.app.state.toast_config.delivery)
@@ -1335,7 +1346,8 @@ impl HeadlessServer {
let msg_text = format!("{}: {}", toast.title, toast.context);
debug!(msg = %msg_text, "forwarding toast notification from API request");
self.send_to_foreground_client(ServerMessage::Notify {
- kind: protocol::NotifyKind::Toast,
+ kind: toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
message: msg_text,
});
true
@@ -1406,7 +1418,8 @@ impl HeadlessServer {
)
);
self.send_to_foreground_client(ServerMessage::Notify {
- kind: protocol::NotifyKind::Toast,
+ kind: toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
message: msg_text,
});
}
@@ -2897,6 +2910,7 @@ mod tests {
Some(client_tx),
),
);
+ server.foreground_client_id = Some(1);
server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady {
@@ -2909,8 +2923,46 @@ mod tests {
client_control_rx
.recv_timeout(Duration::from_millis(50))
.is_err(),
- "herdr delivery should render in-frame instead of forwarding a terminal notification"
+ "herdr delivery should render in-frame instead of forwarding a client-local notification"
+ );
+ }
+
+ #[test]
+ fn system_toast_delivery_forwards_system_notify_kind() {
+ let mut server = test_headless_server();
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
);
+ server.foreground_client_id = Some(1);
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady {
+ version: "9.9.9".to_string(),
+ });
+
+ assert!(changed);
+ match read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("system toast message"),
+ ) {
+ ServerMessage::Notify { kind, message } => {
+ assert_eq!(kind, protocol::NotifyKind::SystemToast);
+ assert_eq!(message, "v9.9.9 available: detach, then run `herdr update`");
+ }
+ other => panic!("expected system toast notify, got {other:?}"),
+ }
}
#[test]
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index 1a220767..e9b7975e 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -247,8 +247,10 @@ pub struct TerminalFrame {
pub enum NotifyKind {
/// Play a sound (bell/agent-done, etc.).
Sound,
- /// Display a toast message.
+ /// Display a toast message through the outer terminal.
Toast,
+ /// Display a toast message through the host OS notification service.
+ SystemToast,
}
/// Messages sent from the server to the client over the client protocol socket.
@@ -754,14 +756,20 @@ mod tests {
#[test]
fn server_notify_roundtrip() {
- let msg = ServerMessage::Notify {
- kind: NotifyKind::Sound,
- message: "agent done".to_owned(),
- };
- 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);
+ for kind in [
+ NotifyKind::Sound,
+ NotifyKind::Toast,
+ NotifyKind::SystemToast,
+ ] {
+ let msg = ServerMessage::Notify {
+ kind,
+ message: "agent done".to_owned(),
+ };
+ 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);
+ }
}
#[test]
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index ef80d85a..abc103d1 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -99,6 +99,7 @@ pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: R
("off", ToastDelivery::Off),
("inside herdr", ToastDelivery::Herdr),
("via terminal", ToastDelivery::Terminal),
+ ("via system", ToastDelivery::System),
],
app.toast_delivery(),
app.settings.list.selected,