aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-25 23:21:42 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-25 23:21:42 +0300
commit88cc44c7892296b1a2fcf91314a4c11651833aa4 (patch)
treea0bf12815ec2dd2bf68a7f29f5cdfc888b32cbea /src/server/headless.rs
parent692327f38b752e868988e789d27e9098e4b7e34e (diff)
fix: keep herdr toast delivery in-app
Diffstat (limited to 'src/server/headless.rs')
-rw-r--r--src/server/headless.rs269
1 files changed, 136 insertions, 133 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs
index b567158c..d5829f71 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -34,7 +34,9 @@ use crate::api;
use crate::app;
use crate::app::state::AppState;
use crate::config;
+use crate::detect::AgentState;
use crate::events::AppEvent;
+use crate::layout::PaneId;
use crate::server::protocol::{
self, ClientMessage, CursorState, FrameData, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
};
@@ -100,6 +102,46 @@ const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB
/// avoid reintroducing the idle CPU spin.
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)
+}
+
+fn toast_event_text(kind: app::state::ToastKind) -> &'static str {
+ match kind {
+ app::state::ToastKind::NeedsAttention => "needs attention",
+ app::state::ToastKind::Finished => "finished",
+ app::state::ToastKind::UpdateInstalled => "updated",
+ }
+}
+
+fn toast_message_from_state_change(
+ state: &AppState,
+ pane_id: PaneId,
+ is_active_tab: bool,
+ prev_state: AgentState,
+ new_state: AgentState,
+) -> Option<String> {
+ let kind =
+ app::actions::notification_toast_for_state_change(is_active_tab, prev_state, new_state)?;
+
+ state
+ .workspaces
+ .iter()
+ .enumerate()
+ .find_map(|(ws_idx, ws)| {
+ ws.tabs.iter().find_map(|tab| {
+ let pane = tab.panes.get(&pane_id)?;
+ let agent_label = pane.effective_agent_label()?;
+ Some(format!(
+ "{} {}: {}",
+ agent_label,
+ toast_event_text(kind),
+ app::actions::notification_context(ws, ws_idx, pane_id)
+ ))
+ })
+ })
+}
+
// ---------------------------------------------------------------------------
// Socket path helpers
// ---------------------------------------------------------------------------
@@ -800,57 +842,22 @@ impl HeadlessServer {
}
let toast_msg =
- 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 if matches!(
- self.app.state.toast_config.delivery,
- crate::config::ToastDelivery::Terminal
- ) {
- self.app
- .state
- .workspaces
- .iter()
- .enumerate()
- .find_map(|(ws_idx, ws)| {
- ws.tabs.iter().find_map(|tab| {
- tab.panes.get(&pane_id_val).and_then(|pane| {
- pane.effective_agent_label().and_then(|agent_label| {
- crate::app::actions::notification_toast_for_state_change(
- is_active_tab,
- prev_state,
- state_val,
- )
- .map(|kind| {
- let event_text = match kind {
- crate::app::state::ToastKind::NeedsAttention => {
- "needs attention"
- }
- crate::app::state::ToastKind::Finished => {
- "finished"
- }
- crate::app::state::ToastKind::UpdateInstalled => {
- "updated"
- }
- };
- format!(
- "{} {}: {}",
- agent_label,
- event_text,
- crate::app::actions::notification_context(
- ws,
- ws_idx,
- pane_id_val,
- )
- )
- })
- })
- })
- })
- })
+ 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,
+ pane_id_val,
+ is_active_tab,
+ prev_state,
+ state_val,
+ )
+ }
} else {
None
};
@@ -933,57 +940,22 @@ impl HeadlessServer {
}
let toast_msg =
- 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 if matches!(
- self.app.state.toast_config.delivery,
- crate::config::ToastDelivery::Terminal
- ) {
- self.app
- .state
- .workspaces
- .iter()
- .enumerate()
- .find_map(|(ws_idx, ws)| {
- ws.tabs.iter().find_map(|tab| {
- tab.panes.get(&pane_id_val).and_then(|pane| {
- pane.effective_agent_label().and_then(|agent_label| {
- crate::app::actions::notification_toast_for_state_change(
- is_active_tab,
- prev_hook_state,
- hook_state_val,
- )
- .map(|kind| {
- let event_text = match kind {
- crate::app::state::ToastKind::NeedsAttention => {
- "needs attention"
- }
- crate::app::state::ToastKind::Finished => {
- "finished"
- }
- crate::app::state::ToastKind::UpdateInstalled => {
- "updated"
- }
- };
- format!(
- "{} {}: {}",
- agent_label,
- event_text,
- crate::app::actions::notification_context(
- ws,
- ws_idx,
- pane_id_val,
- )
- )
- })
- })
- })
- })
- })
+ 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,
+ pane_id_val,
+ is_active_tab,
+ prev_hook_state,
+ hook_state_val,
+ )
+ }
} else {
None
};
@@ -1004,19 +976,18 @@ impl HeadlessServer {
self.app.handle_internal_event(ev);
let toast_msg =
- 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 if matches!(
- self.app.state.toast_config.delivery,
- crate::config::ToastDelivery::Terminal
- ) {
- Some(format!(
- "v{version} available: detach, then run `herdr update`"
- ))
+ 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 {
+ Some(format!(
+ "v{version} available: detach, then run `herdr update`"
+ ))
+ }
} else {
None
};
@@ -1326,23 +1297,29 @@ impl HeadlessServer {
let response = self.app.handle_api_request(msg.request);
let _ = msg.respond_to.send(response);
- // Forward any new toast as a notification to clients.
+ // Forward new toast state only when terminal delivery is selected.
+ // Herdr delivery renders the toast in-frame and must not ask clients to
+ // show a terminal/desktop notification.
let toast_after = self.app.state.toast.clone();
- let forwarded_toast_from_state = if 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_all_clients(ServerMessage::Notify {
- kind: protocol::NotifyKind::Toast,
- message: msg_text,
- });
- true
+ 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_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: msg_text,
+ });
+ true
+ } else {
+ false
+ }
} else {
false
- }
- } else {
- false
- };
+ };
// Forward sound notifications for any pane state changes that occurred
// during the API request. Compare before/after pane states (including
@@ -1403,10 +1380,7 @@ impl HeadlessServer {
);
if !forwarded_toast_from_state
- && matches!(
- self.app.state.toast_config.delivery,
- crate::config::ToastDelivery::Terminal
- )
+ && should_forward_toast_to_clients(self.app.state.toast_config.delivery)
{
if let Some(kind) = crate::app::actions::notification_toast_for_state_change(
is_active_tab,
@@ -2461,6 +2435,35 @@ mod tests {
}
#[test]
+ fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() {
+ let mut server = test_headless_server();
+ let (client_tx, client_rx) = std::sync::mpsc::channel();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 1,
+ last_frame: None,
+ writer: Some(client_tx),
+ },
+ );
+ server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady {
+ version: "9.9.9".to_string(),
+ });
+
+ assert!(changed);
+ assert!(server.app.state.toast.is_some());
+ assert!(
+ client_rx.recv_timeout(Duration::from_millis(50)).is_err(),
+ "herdr delivery should render in-frame instead of forwarding a terminal notification"
+ );
+ }
+
+ #[test]
fn handshake_timeout_is_within_five_second_deadline() {
// The handshake timeout must be short enough that
// the connection is guaranteed to close within 5 seconds even with