aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-06 17:41:44 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-06 17:42:38 +0300
commit4001fd2ec8ff05a982da18744b8cbde9df3ab530 (patch)
treef8dbf984cff9a4b025e86481d3f70c89322bd79f /src/server
parent048d8a29195d580014b24ee3fc7e89ac6bdfee8a (diff)
feat: improve toast ergonomics
refs #486
Diffstat (limited to 'src/server')
-rw-r--r--src/server/headless.rs958
1 files changed, 841 insertions, 117 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs
index c1fc7352..c78df8a1 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -63,6 +63,27 @@ use crate::server::client_transport::ClientWriter;
#[cfg(test)]
use std::fs;
+fn sound_notify_message(sound: crate::sound::Sound) -> &'static str {
+ match sound {
+ crate::sound::Sound::Done => "agent done",
+ crate::sound::Sound::Request => "agent attention",
+ }
+}
+
+fn notification_show_response_shown(response: &str) -> bool {
+ let Ok(response) = serde_json::from_str::<api::schema::SuccessResponse>(response) else {
+ return false;
+ };
+ matches!(
+ response.result,
+ api::schema::ResponseResult::NotificationShow { shown: true, .. }
+ )
+}
+
+fn non_empty_body(value: &str) -> Option<String> {
+ (!value.is_empty()).then(|| value.to_owned())
+}
+
// ---------------------------------------------------------------------------
// Loop event enum for the headless server event loop
// ---------------------------------------------------------------------------
@@ -1297,6 +1318,142 @@ impl HeadlessServer {
.unwrap_or(crate::detect::AgentState::Unknown)
}
+ fn forward_agent_notification_delivery(
+ &mut self,
+ delivery: &crate::app::state::AgentNotificationDelivery,
+ ) {
+ if let Some(sound) = delivery.sound {
+ self.send_notify_to_foreground_client(
+ protocol::NotifyKind::Sound,
+ sound_notify_message(sound),
+ None,
+ );
+ }
+
+ if should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
+ if let Some(toast) = &delivery.client_notification {
+ self.send_notify_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
+ &toast.title,
+ non_empty_body(&toast.context),
+ );
+ }
+ }
+ }
+
+ fn send_notify_to_foreground_client(
+ &mut self,
+ kind: protocol::NotifyKind,
+ message: impl Into<String>,
+ body: Option<String>,
+ ) -> bool {
+ self.send_to_foreground_client(ServerMessage::Notify {
+ kind,
+ message: message.into(),
+ body,
+ })
+ }
+
+ fn send_flat_toast_to_foreground_client(
+ &mut self,
+ kind: protocol::NotifyKind,
+ message: impl AsRef<str>,
+ ) -> bool {
+ let (title, body) = crate::terminal_notify::split_message(message.as_ref());
+ self.send_notify_to_foreground_client(kind, title, body.map(str::to_string))
+ }
+
+ fn handle_notification_show_api(
+ &mut self,
+ id: String,
+ params: api::schema::NotificationShowParams,
+ ) -> String {
+ use api::schema::{NotificationShowReason, ResponseResult};
+
+ let Some(title) = sanitize_notification_text(&params.title, 80) else {
+ return serde_json::to_string(&api::schema::ErrorResponse {
+ id,
+ error: api::schema::ErrorBody {
+ code: "invalid_params".into(),
+ message: "notification title is empty".into(),
+ },
+ })
+ .unwrap_or_else(|_| "{}".to_string());
+ };
+
+ match self.app.state.toast_config.delivery {
+ config::ToastDelivery::Off => {
+ return serde_json::to_string(&api::schema::SuccessResponse {
+ id,
+ result: ResponseResult::NotificationShow {
+ shown: false,
+ reason: NotificationShowReason::Disabled,
+ },
+ })
+ .unwrap_or_else(|_| "{}".to_string());
+ }
+ config::ToastDelivery::Herdr => {
+ let sound = params.sound;
+ let response = self.app.handle_api_request_after_internal_events_drained(
+ api::schema::Request {
+ id,
+ method: api::schema::Method::NotificationShow(params),
+ },
+ );
+ if notification_show_response_shown(&response) {
+ self.forward_api_notification_sound(sound);
+ }
+ return response;
+ }
+ config::ToastDelivery::Terminal | config::ToastDelivery::System => {}
+ }
+
+ let body = params
+ .body
+ .as_deref()
+ .and_then(|body| sanitize_notification_text(body, 240));
+ if self.app.api_notification_rate_limited(Instant::now()) {
+ return serde_json::to_string(&api::schema::SuccessResponse {
+ id,
+ result: ResponseResult::NotificationShow {
+ shown: false,
+ reason: NotificationShowReason::RateLimited,
+ },
+ })
+ .unwrap_or_else(|_| "{}".to_string());
+ }
+ let kind = toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("terminal/system delivery has notify kind");
+ let shown = self.send_notify_to_foreground_client(kind, title, body);
+ if shown {
+ self.app.mark_api_notification_shown(Instant::now());
+ self.forward_api_notification_sound(params.sound);
+ }
+ let reason = if shown {
+ NotificationShowReason::Shown
+ } else {
+ NotificationShowReason::NoForegroundClient
+ };
+
+ serde_json::to_string(&api::schema::SuccessResponse {
+ id,
+ result: ResponseResult::NotificationShow { shown, reason },
+ })
+ .unwrap_or_else(|_| "{}".to_string())
+ }
+
+ fn forward_api_notification_sound(&mut self, sound: api::schema::NotificationShowSound) {
+ let Some(sound) = sound.to_sound() else {
+ return;
+ };
+ self.send_notify_to_foreground_client(
+ protocol::NotifyKind::Sound,
+ sound_notify_message(sound),
+ None,
+ );
+ }
+
/// Handles a single internal event with forwarding logic for clipboard,
/// sound, and toast notifications to connected clients.
///
@@ -1350,51 +1507,51 @@ impl HeadlessServer {
let next_state = self.pane_effective_state(pane_id_val);
- if self.app.state.sound.allows(agent_val) {
+ if self.app.state.toast_config.delay_seconds == 0
+ && self.app.state.sound.allows(agent_val)
+ {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
prev_state,
next_state,
) {
- let msg = match sound {
- crate::sound::Sound::Done => "agent done",
- crate::sound::Sound::Request => "agent attention",
- };
- self.send_to_foreground_client(ServerMessage::Notify {
- kind: protocol::NotifyKind::Sound,
- message: msg.to_owned(),
- });
+ self.send_notify_to_foreground_client(
+ protocol::NotifyKind::Sound,
+ sound_notify_message(sound),
+ None,
+ );
}
}
- let toast_msg =
- if should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
- if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
- self.app
- .state
- .toast
- .as_ref()
- .map(|toast| format!("{}: {}", toast.title, toast.context))
- } else {
- toast_message_from_state_change(
- &self.app.state,
- &self.app.terminal_runtimes,
- pane_id_val,
- suppress_active_tab_notifications,
- prev_state,
- next_state,
- )
- }
+ let toast_msg = if self.app.state.toast_config.delay_seconds == 0
+ && should_forward_toast_to_clients(self.app.state.toast_config.delivery)
+ {
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ self.app
+ .state
+ .toast
+ .as_ref()
+ .map(|toast| format!("{}: {}", toast.title, toast.context))
} else {
- None
- };
+ toast_message_from_state_change(
+ &self.app.state,
+ &self.app.terminal_runtimes,
+ pane_id_val,
+ suppress_active_tab_notifications,
+ prev_state,
+ next_state,
+ )
+ }
+ } else {
+ None
+ };
if let Some(msg) = toast_msg {
- self.send_to_foreground_client(ServerMessage::Notify {
- kind: toast_notify_kind(self.app.state.toast_config.delivery)
+ self.send_flat_toast_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
.expect("toast forwarding requires a client notification kind"),
- message: msg,
- });
+ msg,
+ );
}
true
@@ -1435,51 +1592,51 @@ impl HeadlessServer {
let next_state = self.pane_effective_state(pane_id_val);
- if self.app.state.sound.allows(agent_val) {
+ if self.app.state.toast_config.delay_seconds == 0
+ && self.app.state.sound.allows(agent_val)
+ {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
prev_state,
next_state,
) {
- let msg = match sound {
- crate::sound::Sound::Done => "agent done",
- crate::sound::Sound::Request => "agent attention",
- };
- self.send_to_foreground_client(ServerMessage::Notify {
- kind: protocol::NotifyKind::Sound,
- message: msg.to_owned(),
- });
+ self.send_notify_to_foreground_client(
+ protocol::NotifyKind::Sound,
+ sound_notify_message(sound),
+ None,
+ );
}
}
- let toast_msg =
- if should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
- if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
- self.app
- .state
- .toast
- .as_ref()
- .map(|toast| format!("{}: {}", toast.title, toast.context))
- } else {
- toast_message_from_state_change(
- &self.app.state,
- &self.app.terminal_runtimes,
- pane_id_val,
- suppress_active_tab_notifications,
- prev_state,
- next_state,
- )
- }
+ let toast_msg = if self.app.state.toast_config.delay_seconds == 0
+ && should_forward_toast_to_clients(self.app.state.toast_config.delivery)
+ {
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ self.app
+ .state
+ .toast
+ .as_ref()
+ .map(|toast| format!("{}: {}", toast.title, toast.context))
} else {
- None
- };
+ toast_message_from_state_change(
+ &self.app.state,
+ &self.app.terminal_runtimes,
+ pane_id_val,
+ suppress_active_tab_notifications,
+ prev_state,
+ next_state,
+ )
+ }
+ } else {
+ None
+ };
if let Some(msg) = toast_msg {
- self.send_to_foreground_client(ServerMessage::Notify {
- kind: toast_notify_kind(self.app.state.toast_config.delivery)
+ self.send_flat_toast_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
.expect("toast forwarding requires a client notification kind"),
- message: msg,
- });
+ msg,
+ );
}
true
@@ -1513,11 +1670,11 @@ impl HeadlessServer {
};
if let Some(msg) = toast_msg {
- self.send_to_foreground_client(ServerMessage::Notify {
- kind: toast_notify_kind(self.app.state.toast_config.delivery)
+ self.send_flat_toast_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
.expect("toast forwarding requires a client notification kind"),
- message: msg,
- });
+ msg,
+ );
}
true
@@ -2153,6 +2310,13 @@ impl HeadlessServer {
return true;
}
+ if let api::schema::Method::NotificationShow(params) = &msg.request.method {
+ let response =
+ self.handle_notification_show_api(msg.request.id.clone(), params.clone());
+ let _ = msg.respond_to.send(response);
+ return true;
+ }
+
let mut changed = api::request_changes_ui(&msg.request);
let skip_default_workspace = matches!(
&msg.request.method,
@@ -2218,26 +2382,26 @@ impl HeadlessServer {
// Herdr delivery renders the toast in-frame and must not ask clients to
// 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)
- && toast_after.is_some()
- && toast_after != toast_before
- {
- if let Some(toast) = &toast_after {
- let msg_text = format!("{}: {}", toast.title, toast.context);
- debug!(msg = %msg_text, "forwarding toast notification from API request");
- self.send_to_foreground_client(ServerMessage::Notify {
- kind: toast_notify_kind(self.app.state.toast_config.delivery)
- .expect("toast forwarding requires a client notification kind"),
- message: msg_text,
- });
- true
- } else {
- false
- }
+ let forwarded_toast_from_state = if should_forward_toast_to_clients(
+ self.app.state.toast_config.delivery,
+ ) && toast_after.is_some()
+ && toast_after != toast_before
+ {
+ if let Some(toast) = &toast_after {
+ debug!(title = %toast.title, body = %toast.context, "forwarding toast notification from API request");
+ self.send_notify_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
+ .expect("toast forwarding requires a client notification kind"),
+ &toast.title,
+ non_empty_body(&toast.context),
+ );
+ true
} else {
false
- };
+ }
+ } else {
+ false
+ };
// Forward notifications for effective pane state changes that occurred
// during the API request. Hook authority is already folded into
@@ -2284,6 +2448,7 @@ impl HeadlessServer {
);
if !forwarded_toast_from_state
+ && self.app.state.toast_config.delay_seconds == 0
&& should_forward_toast_to_clients(self.app.state.toast_config.delivery)
{
if let Some(kind) = crate::app::actions::notification_toast_for_state_change(
@@ -2307,43 +2472,37 @@ impl HeadlessServer {
&self.app.state.terminals,
&self.app.terminal_runtimes,
);
- let msg_text = format!(
- "{} {}: {}",
- agent_label,
- event_text,
- crate::app::actions::notification_context(
- &self.app.state.workspaces[*ws_idx],
- &workspace_label,
- *ws_idx,
- *pane_id,
- )
+ let context = crate::app::actions::notification_context(
+ &self.app.state.workspaces[*ws_idx],
+ &workspace_label,
+ *ws_idx,
+ *pane_id,
);
- self.send_to_foreground_client(ServerMessage::Notify {
- kind: toast_notify_kind(self.app.state.toast_config.delivery)
+ self.send_notify_to_foreground_client(
+ toast_notify_kind(self.app.state.toast_config.delivery)
.expect("toast forwarding requires a client notification kind"),
- message: msg_text,
- });
+ format!("{agent_label} {event_text}"),
+ non_empty_body(&context),
+ );
}
}
}
// 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 self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent)
+ {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
*prev_state,
new_state,
) {
- let msg_text = match sound {
- crate::sound::Sound::Done => "agent done",
- crate::sound::Sound::Request => "agent attention",
- };
debug!(sound = ?sound, "forwarding sound notification from API request");
- self.send_to_foreground_client(ServerMessage::Notify {
- kind: protocol::NotifyKind::Sound,
- message: msg_text.to_owned(),
- });
+ self.send_notify_to_foreground_client(
+ protocol::NotifyKind::Sound,
+ sound_notify_message(sound),
+ None,
+ );
}
}
}
@@ -2921,6 +3080,25 @@ impl HeadlessServer {
if self
.app
+ .state
+ .next_pending_agent_notification_deadline()
+ .is_some_and(|deadline| now >= deadline)
+ {
+ let previous_toast = self.app.state.toast.clone();
+ let mut deliveries = self.app.state.drain_due_agent_notifications(now);
+ if !deliveries.is_empty() {
+ self.app
+ .refresh_agent_notification_delivery_contexts(&mut deliveries);
+ self.app.sync_toast_deadline(previous_toast);
+ for delivery in &deliveries {
+ self.forward_agent_notification_delivery(delivery);
+ }
+ changed = true;
+ }
+ }
+
+ if self
+ .app
.copy_feedback_deadline
.is_some_and(|deadline| now >= deadline)
{
@@ -3111,6 +3289,38 @@ async fn sleep_until_or_pending(deadline: Option<Instant>) {
}
}
+fn sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> {
+ let mut sanitized = String::new();
+ let mut previous_space = false;
+ for ch in value.chars() {
+ let replacement = if ch == '\n' || ch == '\r' || ch == '\t' {
+ Some(' ')
+ } else if ch.is_control() {
+ None
+ } else {
+ Some(ch)
+ };
+ let Some(ch) = replacement else {
+ continue;
+ };
+ if ch.is_whitespace() {
+ if previous_space {
+ continue;
+ }
+ previous_space = true;
+ sanitized.push(' ');
+ } else {
+ previous_space = false;
+ sanitized.push(ch);
+ }
+ if sanitized.chars().count() >= max_chars {
+ break;
+ }
+ }
+ let sanitized = sanitized.trim().to_string();
+ (!sanitized.is_empty()).then_some(sanitized)
+}
+
fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) {
let without_keybindings = diagnostics
.iter()
@@ -5678,6 +5888,7 @@ next_tab = ""
kind: crate::app::state::ToastKind::NeedsAttention,
title: "pi needs attention".to_owned(),
context: "background · 2".to_owned(),
+ position: None,
target: None,
});
server.render_and_stream();
@@ -6182,7 +6393,8 @@ next_tab = ""
assert!(server.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Toast,
- message: "pi finished: workspace 1".to_string(),
+ message: "pi finished".to_string(),
+ body: Some("workspace 1".to_string()),
}));
match read_server_message(
@@ -6190,9 +6402,14 @@ next_tab = ""
.recv_timeout(Duration::from_millis(100))
.expect("foreground toast message"),
) {
- ServerMessage::Notify { kind, message } => {
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
assert_eq!(kind, protocol::NotifyKind::Toast);
- assert_eq!(message, "pi finished: workspace 1");
+ assert_eq!(message, "pi finished");
+ assert_eq!(body.as_deref(), Some("workspace 1"));
}
other => panic!("expected toast notify, got {other:?}"),
}
@@ -6270,11 +6487,16 @@ next_tab = ""
.recv_timeout(Duration::from_millis(100))
.expect("system toast message"),
) {
- ServerMessage::Notify { kind, message } => {
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
assert_eq!(kind, protocol::NotifyKind::SystemToast);
+ assert_eq!(message, "v9.9.9 available");
assert_eq!(
- message,
- "v9.9.9 available: detach, run `herdr update`, then follow its restart guidance"
+ body.as_deref(),
+ Some("detach, run `herdr update`, then follow its restart guidance")
);
}
other => panic!("expected system toast notify, got {other:?}"),
@@ -6282,6 +6504,508 @@ next_tab = ""
}
#[test]
+ fn notification_show_api_forwards_system_notification_to_foreground_client() {
+ 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 (respond_to, response_rx) = std::sync::mpsc::channel();
+ let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(
+ api::schema::NotificationShowParams {
+ title: "build failed".into(),
+ body: Some("api workspace".into()),
+ position: Some(crate::config::ToastHerdrPosition::TopLeft),
+ sound: api::schema::NotificationShowSound::Request,
+ },
+ ),
+ },
+ respond_to,
+ });
+
+ assert!(changed);
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ parsed.result,
+ api::schema::ResponseResult::NotificationShow {
+ shown: true,
+ reason: api::schema::NotificationShowReason::Shown,
+ }
+ );
+ let first = read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("api notification message"),
+ );
+ let second = read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("api sound message"),
+ );
+
+ match first {
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
+ assert_eq!(kind, protocol::NotifyKind::SystemToast);
+ assert_eq!(message, "build failed");
+ assert_eq!(body.as_deref(), Some("api workspace"));
+ }
+ other => panic!("expected api notification, got {other:?}"),
+ }
+ match second {
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
+ assert_eq!(kind, protocol::NotifyKind::Sound);
+ assert_eq!(message, "agent attention");
+ assert!(body.is_none());
+ }
+ other => panic!("expected api sound, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn notification_show_api_preserves_colon_in_forwarded_title() {
+ 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 (respond_to, response_rx) = std::sync::mpsc::channel();
+ let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(
+ api::schema::NotificationShowParams {
+ title: "build: failed".into(),
+ body: Some("api workspace".into()),
+ position: None,
+ sound: api::schema::NotificationShowSound::None,
+ },
+ ),
+ },
+ respond_to,
+ });
+
+ assert!(changed);
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ parsed.result,
+ api::schema::ResponseResult::NotificationShow {
+ shown: true,
+ reason: api::schema::NotificationShowReason::Shown,
+ }
+ );
+ match read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("api notification message"),
+ ) {
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
+ assert_eq!(kind, protocol::NotifyKind::SystemToast);
+ assert_eq!(message, "build: failed");
+ assert_eq!(body.as_deref(), Some("api workspace"));
+ }
+ other => panic!("expected api notification, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn notification_show_api_validates_empty_title_before_disabled_delivery() {
+ let mut server = test_headless_server();
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::Off;
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(
+ api::schema::NotificationShowParams {
+ title: "\n\t".into(),
+ body: None,
+ position: None,
+ sound: api::schema::NotificationShowSound::None,
+ },
+ ),
+ },
+ respond_to,
+ });
+
+ assert!(changed);
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(parsed.error.code, "invalid_params");
+ assert_eq!(parsed.error.message, "notification title is empty");
+ }
+
+ #[test]
+ fn notification_show_api_reports_no_foreground_client() {
+ let mut server = test_headless_server();
+ server.foreground_client_id = None;
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(
+ api::schema::NotificationShowParams {
+ title: "build failed".into(),
+ body: None,
+ position: None,
+ sound: api::schema::NotificationShowSound::Request,
+ },
+ ),
+ },
+ respond_to,
+ });
+
+ assert!(changed);
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ parsed.result,
+ api::schema::ResponseResult::NotificationShow {
+ shown: false,
+ reason: api::schema::NotificationShowReason::NoForegroundClient,
+ }
+ );
+ }
+
+ #[test]
+ fn notification_show_api_herdr_toast_expires_headless() {
+ let mut server = test_headless_server();
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ assert!(
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(
+ api::schema::NotificationShowParams {
+ title: "build failed".into(),
+ body: None,
+ position: None,
+ sound: api::schema::NotificationShowSound::None,
+ },
+ ),
+ },
+ respond_to,
+ })
+ );
+
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ parsed.result,
+ api::schema::ResponseResult::NotificationShow {
+ shown: true,
+ reason: api::schema::NotificationShowReason::Shown,
+ }
+ );
+ let deadline = server.app.toast_deadline.expect("api toast deadline");
+ assert!(server.handle_scheduled_tasks_headless(deadline, false));
+ assert!(server.app.state.toast.is_none());
+ assert!(server.app.toast_deadline.is_none());
+ }
+
+ #[test]
+ fn notification_show_api_forwards_sound_for_herdr_delivery() {
+ 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::Herdr;
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ assert!(
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "notify".into(),
+ method: api::schema::Method::NotificationShow(
+ api::schema::NotificationShowParams {
+ title: "build failed".into(),
+ body: None,
+ position: None,
+ sound: api::schema::NotificationShowSound::Done,
+ },
+ ),
+ },
+ respond_to,
+ })
+ );
+
+ let response = response_rx
+ .recv_timeout(Duration::from_millis(100))
+ .unwrap();
+ let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ parsed.result,
+ api::schema::ResponseResult::NotificationShow {
+ shown: true,
+ reason: api::schema::NotificationShowReason::Shown,
+ }
+ );
+ match read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("api sound message"),
+ ) {
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
+ assert_eq!(kind, protocol::NotifyKind::Sound);
+ assert_eq!(message, "agent done");
+ assert!(body.is_none());
+ }
+ other => panic!("expected api sound, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn delayed_agent_notification_forwards_after_deadline() {
+ let mut server = test_headless_server();
+ let background = crate::workspace::Workspace::test_new("background");
+ let pane_id = background.tabs[0].root_pane;
+ let foreground = crate::workspace::Workspace::test_new("foreground");
+ server.app.state.workspaces = vec![background, foreground];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(1);
+ server.app.state.selected = 1;
+ server.app.state.mode = crate::app::Mode::Terminal;
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
+ server.app.state.toast_config.delay_seconds = 1;
+
+ 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.sync_foreground_client_state();
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(crate::detect::Agent::Pi),
+ state: crate::detect::AgentState::Blocked,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: Instant::now(),
+ });
+
+ assert!(changed);
+ assert!(server.app.state.toast.is_none());
+ assert!(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "delayed transition should not notify immediately"
+ );
+
+ let deadline = server
+ .app
+ .state
+ .next_pending_agent_notification_deadline()
+ .expect("pending notification deadline");
+ assert!(server.handle_scheduled_tasks_headless(deadline, false));
+
+ let first = read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("delayed sound message"),
+ );
+ let second = read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("delayed toast message"),
+ );
+
+ assert!(matches!(
+ first,
+ ServerMessage::Notify {
+ kind: protocol::NotifyKind::Sound,
+ ..
+ }
+ ));
+ match second {
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
+ assert_eq!(kind, protocol::NotifyKind::SystemToast);
+ assert_eq!(message, "pi needs attention");
+ assert_eq!(body.as_deref(), Some("background · 1"));
+ }
+ other => panic!("expected delayed system toast, got {other:?}"),
+ }
+ assert!(server.app.state.pending_agent_notifications.is_empty());
+ }
+
+ #[test]
+ fn delayed_active_tab_unfocused_agent_notification_forwards_after_deadline() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("active");
+ let pane_id = workspace.tabs[0].root_pane;
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
+ server.app.state.toast_config.delay_seconds = 1;
+
+ 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(),
+ Some(false),
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ assert!(
+ server.handle_internal_event_with_forwarding(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(crate::detect::Agent::Pi),
+ state: crate::detect::AgentState::Blocked,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: Instant::now(),
+ })
+ );
+ assert!(server.app.state.toast.is_none());
+ assert!(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "delayed transition should not notify immediately"
+ );
+
+ let deadline = server
+ .app
+ .state
+ .next_pending_agent_notification_deadline()
+ .expect("pending notification deadline");
+ assert!(server.handle_scheduled_tasks_headless(deadline, false));
+
+ let first = read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("delayed sound message"),
+ );
+ let second = read_server_message(
+ client_control_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("delayed toast message"),
+ );
+
+ assert!(matches!(
+ first,
+ ServerMessage::Notify {
+ kind: protocol::NotifyKind::Sound,
+ ..
+ }
+ ));
+ match second {
+ ServerMessage::Notify {
+ kind,
+ message,
+ body,
+ } => {
+ assert_eq!(kind, protocol::NotifyKind::SystemToast);
+ assert_eq!(message, "pi needs attention");
+ assert_eq!(body.as_deref(), Some("active · 1"));
+ }
+ other => panic!("expected delayed system toast, got {other:?}"),
+ }
+ }
+
+ #[test]
fn stale_api_agent_report_does_not_forward_done_sound() {
let mut server = test_headless_server();
let background = crate::workspace::Workspace::test_new("background");