diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-06 17:41:44 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-06 17:42:38 +0300 |
| commit | 4001fd2ec8ff05a982da18744b8cbde9df3ab530 (patch) | |
| tree | f8dbf984cff9a4b025e86481d3f70c89322bd79f /src/app | |
| parent | 048d8a29195d580014b24ee3fc7e89ac6bdfee8a (diff) | |
feat: improve toast ergonomics
refs #486
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 405 | ||||
| -rw-r--r-- | src/app/api.rs | 301 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 2 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 4 | ||||
| -rw-r--r-- | src/app/mod.rs | 185 | ||||
| -rw-r--r-- | src/app/runtime.rs | 16 | ||||
| -rw-r--r-- | src/app/state.rs | 26 |
7 files changed, 900 insertions, 39 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 17daa6ff..06ffe51b 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -12,8 +12,9 @@ use crate::workspace::WorkspaceGitStatus; use unicode_width::UnicodeWidthChar; use super::state::{ - text_matches_query, AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, - PaneFocusTarget, ToastKind, ToastNotification, ToastTarget, ViewLayout, + text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, + NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, PendingAgentNotification, ToastKind, + ToastNotification, ToastTarget, ViewLayout, }; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { @@ -71,6 +72,27 @@ fn toast_agent_label(agent_label: &str) -> &str { agent_label } +fn toast_event_text(kind: ToastKind) -> &'static str { + match kind { + ToastKind::NeedsAttention => "needs attention", + ToastKind::Finished => "finished", + ToastKind::UpdateInstalled => "updated", + } +} + +fn sound_for_toast_kind( + kind: ToastKind, + suppress_active_tab_notifications: bool, +) -> Option<crate::sound::Sound> { + match kind { + ToastKind::NeedsAttention => Some(crate::sound::Sound::Request), + ToastKind::Finished if !suppress_active_tab_notifications => { + Some(crate::sound::Sound::Done) + } + ToastKind::Finished | ToastKind::UpdateInstalled => None, + } +} + pub fn notification_context( ws: &crate::workspace::Workspace, workspace_label: &str, @@ -2174,6 +2196,7 @@ impl AppState { kind: ToastKind::UpdateInstalled, title: format!("v{version} available"), context: crate::update::update_install_instruction(&install_command), + position: None, target: None, }); } @@ -2384,12 +2407,149 @@ impl AppState { } let seen = pane.seen; - if self.local_sound_playback && self.sound.allows(change.known_agent) { - if let Some(sound) = notification_sound_for_state_change( - suppress_active_tab_notifications, - change.previous_state, + if let Some(delivery) = self.record_or_deliver_agent_notification(ws_idx, pane_id, change) { + self.apply_agent_notification_delivery(&delivery); + } + + Some(seen) + } + + fn record_or_deliver_agent_notification( + &mut self, + ws_idx: usize, + pane_id: PaneId, + change: &EffectiveStateChange, + ) -> Option<AgentNotificationDelivery> { + self.pending_agent_notifications.remove(&pane_id); + + let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); + let suppress_active_tab_notifications = + active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus); + + let client_notification_kind = notification_toast_for_state_change( + suppress_active_tab_notifications, + change.previous_state, + change.state, + ); + let sound = notification_sound_for_state_change( + suppress_active_tab_notifications, + change.previous_state, + change.state, + ); + if client_notification_kind.is_none() && sound.is_none() { + return None; + } + + let agent_label = change.agent_label.clone()?; + let kind = client_notification_kind.unwrap_or(match sound { + Some(crate::sound::Sound::Request) => ToastKind::NeedsAttention, + Some(crate::sound::Sound::Done) | None => ToastKind::Finished, + }); + let workspace_id = self.workspaces[ws_idx].id.clone(); + + if self.toast_config.delay_seconds == 0 { + return self.agent_notification_delivery( + ws_idx, + pane_id, + workspace_id, + agent_label, + change.known_agent, + kind, change.state, - ) { + ); + } + + self.pending_agent_notifications.insert( + pane_id, + PendingAgentNotification { + pane_id, + workspace_id, + agent_label, + known_agent: change.known_agent, + kind, + state: change.state, + deadline: { + let now = std::time::Instant::now(); + let delay_seconds = self + .toast_config + .delay_seconds + .min(crate::config::MAX_TOAST_DELAY_SECONDS); + now.checked_add(std::time::Duration::from_secs(delay_seconds)) + .unwrap_or(now) + }, + }, + ); + None + } + + fn agent_notification_delivery( + &self, + ws_idx: usize, + pane_id: PaneId, + workspace_id: String, + agent_label: String, + known_agent: Option<Agent>, + kind: ToastKind, + expected_state: AgentState, + ) -> Option<AgentNotificationDelivery> { + let terminal_state = self + .workspaces + .get(ws_idx)? + .pane_state(pane_id) + .and_then(|pane| self.terminals.get(&pane.attached_terminal_id))?; + if terminal_state.state != expected_state { + return None; + } + if terminal_state.effective_agent_label() != Some(agent_label.as_str()) { + return None; + } + + let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); + let suppress_active_tab_notifications = + active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus); + let sound = sound_for_toast_kind(kind, suppress_active_tab_notifications) + .filter(|_| self.sound.allows(known_agent)); + let build_toast = || { + let workspace_label = self.workspaces[ws_idx].display_name(); + let context = + notification_context(&self.workspaces[ws_idx], &workspace_label, ws_idx, pane_id); + ToastNotification { + kind, + title: format!( + "{} {}", + toast_agent_label(&agent_label), + toast_event_text(kind) + ), + context, + position: None, + target: Some(ToastTarget { + workspace_id: workspace_id.clone(), + pane_id, + }), + } + }; + let toast = (!is_active_tab).then(&build_toast); + let client_notification = (!suppress_active_tab_notifications).then(build_toast); + + if toast.is_none() && client_notification.is_none() && sound.is_none() { + return None; + } + + Some(AgentNotificationDelivery { + pane_id, + workspace_id, + agent_label, + known_agent, + kind, + toast, + client_notification, + sound, + }) + } + + fn apply_agent_notification_delivery(&mut self, delivery: &AgentNotificationDelivery) { + if self.local_sound_playback { + if let Some(sound) = delivery.sound { crate::sound::play(sound, &self.sound); } } @@ -2398,42 +2558,61 @@ impl AppState { self.toast_config.delivery, crate::config::ToastDelivery::Herdr ) { - if let (Some(agent_label), Some(kind)) = ( - change.agent_label.as_deref(), - notification_toast_for_state_change( - is_active_tab, - change.previous_state, - change.state, - ), - ) { - let event_text = match kind { - ToastKind::NeedsAttention => "needs attention", - ToastKind::Finished => "finished", - ToastKind::UpdateInstalled => "updated", - }; - let workspace_label = self.workspaces[ws_idx].display_name(); - let context = notification_context( - &self.workspaces[ws_idx], - &workspace_label, - ws_idx, - pane_id, - ); - self.toast = Some(ToastNotification { - kind, - title: format!("{} {}", toast_agent_label(agent_label), event_text), - context, - target: Some(ToastTarget { - workspace_id: self.workspaces[ws_idx].id.clone(), - pane_id, - }), - }); + if let Some(toast) = delivery.toast.clone() { + self.toast = Some(toast); } } + } - Some(seen) + pub fn next_pending_agent_notification_deadline(&self) -> Option<std::time::Instant> { + self.pending_agent_notifications + .values() + .map(|pending| pending.deadline) + .min() + } + + pub fn drain_due_agent_notifications( + &mut self, + now: std::time::Instant, + ) -> Vec<AgentNotificationDelivery> { + let due_panes: Vec<PaneId> = self + .pending_agent_notifications + .iter() + .filter_map(|(&pane_id, pending)| (pending.deadline <= now).then_some(pane_id)) + .collect(); + let mut deliveries = Vec::new(); + + for pane_id in due_panes { + let Some(pending) = self.pending_agent_notifications.remove(&pane_id) else { + continue; + }; + let Some(ws_idx) = self + .workspaces + .iter() + .position(|ws| ws.id == pending.workspace_id) + else { + continue; + }; + let Some(delivery) = self.agent_notification_delivery( + ws_idx, + pending.pane_id, + pending.workspace_id, + pending.agent_label, + pending.known_agent, + pending.kind, + pending.state, + ) else { + continue; + }; + self.apply_agent_notification_delivery(&delivery); + deliveries.push(delivery); + } + + deliveries } fn handle_pane_died(&mut self, pane_id: PaneId) { + self.pending_agent_notifications.remove(&pane_id); let ws_idx = self .workspaces .iter() @@ -2500,6 +2679,7 @@ mod tests { fn app_with_workspaces(names: &[&str]) -> AppState { let mut state = AppState::test_new(); + state.toast_config.delay_seconds = 0; for name in names { let ws = Workspace::test_new(name); state.workspaces.push(ws); @@ -3785,6 +3965,157 @@ mod tests { } #[test] + fn delayed_background_waiting_schedules_before_toast() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.toast_config.delay_seconds = 1; + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + assert!(state.toast.is_none()); + assert!(state.pending_agent_notifications.contains_key(&bg_pane_id)); + + let deadline = state.next_pending_agent_notification_deadline().unwrap(); + let deliveries = state.drain_due_agent_notifications(deadline); + assert_eq!(deliveries.len(), 1); + + let toast = state.toast.as_ref().unwrap(); + assert_eq!(toast.kind, ToastKind::NeedsAttention); + assert_eq!(toast.title, "pi needs attention"); + assert_eq!(toast.context, "background · 2"); + assert!(state.pending_agent_notifications.is_empty()); + } + + #[test] + fn delayed_background_waiting_cancels_when_agent_resumes_working() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.toast_config.delay_seconds = 1; + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + let deadline = state.next_pending_agent_notification_deadline().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Working, + visible_blocker: false, + visible_idle: false, + visible_working: true, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + assert!(state.pending_agent_notifications.is_empty()); + assert!(state.drain_due_agent_notifications(deadline).is_empty()); + assert!(state.toast.is_none()); + } + + #[test] + fn delayed_background_waiting_is_suppressed_if_pane_becomes_active() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.toast_config.delay_seconds = 1; + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + let deadline = state.next_pending_agent_notification_deadline().unwrap(); + state.active = Some(1); + + assert!(state.drain_due_agent_notifications(deadline).is_empty()); + assert!(state.toast.is_none()); + } + + #[test] + fn delayed_active_tab_unfocused_keeps_client_notification_available() { + let mut state = app_with_workspaces(&["active"]); + state.active = Some(0); + state.outer_terminal_focus = Some(false); + state.toast_config.delivery = crate::config::ToastDelivery::System; + state.toast_config.delay_seconds = 1; + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + let deadline = state.next_pending_agent_notification_deadline().unwrap(); + let deliveries = state.drain_due_agent_notifications(deadline); + + assert_eq!(deliveries.len(), 1); + assert!(deliveries[0].toast.is_none()); + assert!(deliveries[0].client_notification.is_some()); + assert!(state.toast.is_none()); + } + + #[test] + fn delayed_background_waiting_is_cleared_when_pane_dies() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.toast_config.delay_seconds = 1; + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + let deadline = state.next_pending_agent_notification_deadline().unwrap(); + state.handle_app_event(AppEvent::PaneDied { + pane_id: bg_pane_id, + }); + + assert!(state.pending_agent_notifications.is_empty()); + assert!(state.drain_due_agent_notifications(deadline).is_empty()); + assert!(state.toast.is_none()); + } + + #[test] fn hook_reported_unknown_agent_sets_toast_title_from_label() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); diff --git a/src/app/api.rs b/src/app/api.rs index 592ee7da..4bb89a77 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -12,6 +12,8 @@ mod worktrees; use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind}; use crate::events::AppEvent; +const API_NOTIFICATION_RATE_LIMIT: Duration = Duration::from_secs(1); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RuntimeExitAction { RespawnShell, @@ -154,7 +156,7 @@ impl App { if let Some((version, install_command)) = update_ready { let instruction = crate::update::update_install_instruction(&install_command); let _ = notify(&format!("v{version} available"), Some(&instruction)); - } else { + } else if self.state.toast_config.delay_seconds == 0 { for update in &pane_updates { let is_active_tab = self .state @@ -257,6 +259,11 @@ impl App { } pub(crate) fn show_clipboard_feedback(&mut self) { + if !self.state.toast_config.clipboard.enabled { + self.state.copy_feedback = None; + self.copy_feedback_deadline = None; + return; + } self.state.copy_feedback = Some(crate::app::state::CopyFeedback { message: "copied to clipboard".to_string(), }); @@ -396,7 +403,7 @@ impl App { } } - pub(super) fn sync_toast_deadline( + pub(crate) fn sync_toast_deadline( &mut self, previous_toast: Option<crate::app::state::ToastNotification>, ) { @@ -412,6 +419,72 @@ impl App { } } + pub(crate) fn emit_delayed_client_local_agent_notifications( + &self, + deliveries: &[crate::app::state::AgentNotificationDelivery], + ) { + if !self.local_terminal_notifications + || !matches!( + self.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System + ) + { + return; + } + + 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"), + }; + + for delivery in deliveries { + let Some(toast) = &delivery.client_notification else { + continue; + }; + let _ = notify(&toast.title, Some(&toast.context)); + } + } + + pub(crate) fn refresh_agent_notification_delivery_contexts( + &mut self, + deliveries: &mut [crate::app::state::AgentNotificationDelivery], + ) { + for delivery in deliveries { + let Some(ws_idx) = self + .state + .workspaces + .iter() + .position(|ws| ws.id == delivery.workspace_id) + else { + continue; + }; + let ws = &self.state.workspaces[ws_idx]; + let workspace_label = + ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); + let context = crate::app::actions::notification_context( + ws, + &workspace_label, + ws_idx, + delivery.pane_id, + ); + if let Some(toast) = delivery.toast.as_mut() { + toast.context = context.clone(); + } + if let Some(toast) = delivery.client_notification.as_mut() { + toast.context = context.clone(); + } + if let Some(toast) = self.state.toast.as_mut() { + if toast.target.as_ref().is_some_and(|target| { + target.workspace_id == delivery.workspace_id + && target.pane_id == delivery.pane_id + }) { + toast.context = context; + } + } + } + } + pub(super) fn emit_event(&self, event: crate::api::schema::EventEnvelope) { self.event_hub.push(event); } @@ -519,6 +592,9 @@ impl App { }, } } + Method::NotificationShow(params) => { + return self.handle_notification_show(request.id, params); + } Method::WorkspaceList(_) => return self.handle_workspace_list(request.id), Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target), Method::WorkspaceCreate(params) => { @@ -606,6 +682,128 @@ impl App { serde_json::to_string(&response).unwrap() } + + fn handle_notification_show( + &mut self, + id: String, + params: crate::api::schema::NotificationShowParams, + ) -> String { + use crate::api::schema::{NotificationShowReason, ResponseResult}; + + let requested_sound = params.sound; + let Some(title) = sanitized_notification_text(¶ms.title, 80) else { + return responses::encode_error(id, "invalid_params", "notification title is empty"); + }; + let body = params + .body + .as_deref() + .and_then(|body| sanitized_notification_text(body, 240)); + + let reason = match self.state.toast_config.delivery { + crate::config::ToastDelivery::Off => NotificationShowReason::Disabled, + crate::config::ToastDelivery::Herdr => { + if self.state.toast.is_some() { + NotificationShowReason::Busy + } else if self.api_notification_rate_limited(Instant::now()) { + NotificationShowReason::RateLimited + } else { + let previous_toast = self.state.toast.clone(); + self.mark_api_notification_shown(Instant::now()); + self.state.toast = Some(crate::app::state::ToastNotification { + kind: ToastKind::UpdateInstalled, + title, + context: body.unwrap_or_default(), + position: params.position, + target: None, + }); + self.sync_toast_deadline(previous_toast); + self.emit_api_notification_sound(requested_sound); + NotificationShowReason::Shown + } + } + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => { + if self.api_notification_rate_limited(Instant::now()) { + NotificationShowReason::RateLimited + } else { + 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!("notification delivery was checked above"), + }; + match notify(&title, body.as_deref()) { + Ok(true) => { + self.mark_api_notification_shown(Instant::now()); + self.emit_api_notification_sound(requested_sound); + NotificationShowReason::Shown + } + Ok(false) | Err(_) => NotificationShowReason::NoForegroundClient, + } + } + } + }; + + responses::encode_success( + id, + ResponseResult::NotificationShow { + shown: matches!(reason, NotificationShowReason::Shown), + reason, + }, + ) + } + + fn emit_api_notification_sound(&self, sound: crate::api::schema::NotificationShowSound) { + if !self.state.local_sound_playback || !self.state.sound.allows(None) { + return; + } + if let Some(sound) = sound.to_sound() { + crate::sound::play(sound, &self.state.sound); + } + } + + pub(crate) fn api_notification_rate_limited(&self, now: Instant) -> bool { + self.last_api_notification_at + .is_some_and(|last| now.duration_since(last) < API_NOTIFICATION_RATE_LIMIT) + } + + pub(crate) fn mark_api_notification_shown(&mut self, now: Instant) { + self.last_api_notification_at = Some(now); + } +} + +fn sanitized_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) } #[cfg(test)] @@ -660,6 +858,7 @@ mod tests { app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.state.toast_config.delay_seconds = 0; let (events, _) = tokio::sync::mpsc::channel(4); let runtime = crate::terminal::TerminalRuntime::spawn( @@ -714,6 +913,103 @@ mod tests { } #[tokio::test] + async fn delayed_herdr_toast_context_uses_live_root_runtime_cwd_label() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + + let mut workspace = crate::workspace::Workspace::test_new("stale"); + workspace.custom_name = None; + let root = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(root).cloned().unwrap(); + let temp_root = std::env::temp_dir().join(format!( + "herdr-delayed-toast-context-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let stale_cwd = temp_root.join("__herdr_original__"); + let live_cwd = temp_root.join("__herdr_projects__"); + std::fs::create_dir_all(&stale_cwd).unwrap(); + std::fs::create_dir_all(&live_cwd).unwrap(); + init_repo(&stale_cwd); + init_repo(&live_cwd); + + workspace.identity_cwd = stale_cwd.clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; + app.state.active = None; + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.state.toast_config.delay_seconds = 1; + + let (events, _) = tokio::sync::mpsc::channel(4); + let runtime = crate::terminal::TerminalRuntime::spawn( + root, + 24, + 80, + live_cwd.clone(), + 0, + crate::terminal_theme::TerminalTheme::default(), + crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + events, + std::sync::Arc::new(tokio::sync::Notify::new()), + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + app.terminal_runtimes.insert(terminal_id, runtime); + + app.handle_internal_event(AppEvent::StateChanged { + pane_id: root, + agent: Some(Agent::Codex), + state: AgentState::Working, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + app.handle_internal_event(AppEvent::StateChanged { + pane_id: root, + agent: Some(Agent::Codex), + state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + let notification_deadline = app + .state + .next_pending_agent_notification_deadline() + .expect("pending notification deadline"); + assert!(app.handle_scheduled_tasks(notification_deadline, false)); + assert_eq!( + app.state.toast.as_ref().map(|toast| toast.context.as_str()), + Some("__herdr_projects__ · 1") + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(temp_root); + } + + #[tokio::test] async fn pane_died_respawns_shell_and_clears_restored_agent_session() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( @@ -802,6 +1098,7 @@ mod tests { kind: ToastKind::Finished, title: "codex finished".into(), context: "__herdr_original__ · 1".into(), + position: None, target: Some(crate::app::state::ToastTarget { workspace_id, pane_id: root, diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 9272c279..ae10c0fa 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -2244,6 +2244,7 @@ mod tests { app.state.active = Some(0); app.state.selected = 0; app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.state.toast_config.delay_seconds = 0; let target_terminal_id = app.state.workspaces[1] .panes .get(&target_pane) @@ -2305,6 +2306,7 @@ mod tests { kind: crate::app::state::ToastKind::Finished, title: "pi finished".into(), context: "background · 2".into(), + position: None, target: Some(crate::app::state::ToastTarget { workspace_id, pane_id: target_pane, diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 7f803825..8dacc5c9 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -148,6 +148,7 @@ impl App { kind: crate::app::state::ToastKind::NeedsAttention, title: "custom command failed".to_string(), context: err.to_string(), + position: None, target: None, }); self.sync_toast_deadline(previous_toast); @@ -229,6 +230,7 @@ impl App { kind: crate::app::state::ToastKind::NeedsAttention, title: "edit scrollback failed".to_string(), context: err.to_string(), + position: None, target: None, }); self.sync_toast_deadline(previous_toast); @@ -271,6 +273,7 @@ impl App { kind: crate::app::state::ToastKind::Finished, title: "opened scrollback".to_string(), context: format!("focused pane {public_pane_id}"), + position: None, target: None, }); } @@ -1295,6 +1298,7 @@ mod tests { kind: crate::app::state::ToastKind::NeedsAttention, title: "pi needs attention".into(), context: "two".into(), + position: None, target: Some(crate::app::state::ToastTarget { workspace_id: target_workspace_id, pane_id: target_pane, diff --git a/src/app/mod.rs b/src/app/mod.rs index a9408736..6a387c9a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -97,6 +97,7 @@ pub struct App { pub(crate) config_diagnostic_deadline: Option<Instant>, pub(crate) toast_deadline: Option<Instant>, pub(crate) copy_feedback_deadline: Option<Instant>, + pub(crate) last_api_notification_at: Option<Instant>, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) git_refresh_in_flight: bool, pub(crate) git_refresh_due_after_in_flight: bool, @@ -479,6 +480,7 @@ impl App { update_dismissed: false, config_diagnostic, toast: None, + pending_agent_notifications: std::collections::HashMap::new(), copy_feedback: None, outer_terminal_focus: None, prefix_code, @@ -568,6 +570,7 @@ impl App { config_diagnostic_deadline: None, toast_deadline: None, copy_feedback_deadline: None, + last_api_notification_at: None, state, terminal_runtimes: restored_terminal_runtimes, event_tx, @@ -1263,6 +1266,7 @@ impl App { kind: crate::app::state::ToastKind::UpdateInstalled, title: "reloaded config".to_string(), context: "using config.toml".to_string(), + position: None, target: None, }); } @@ -1274,6 +1278,7 @@ impl App { kind: crate::app::state::ToastKind::UpdateInstalled, title: "reloaded config".to_string(), context: "with warnings".to_string(), + position: None, target: None, }); } @@ -1730,12 +1735,26 @@ mod tests { } #[test] + fn clipboard_feedback_can_be_disabled() { + let mut app = test_app(); + app.state.toast_config.clipboard.enabled = false; + + app.handle_internal_event(AppEvent::ClipboardWrite { + content: b"copied".to_vec(), + }); + + assert!(app.state.copy_feedback.is_none()); + assert!(app.copy_feedback_deadline.is_none()); + } + + #[test] fn clipboard_feedback_does_not_replace_notification_toast() { let mut app = test_app(); app.state.toast = Some(crate::app::state::ToastNotification { kind: crate::app::state::ToastKind::NeedsAttention, title: "pi needs attention".to_string(), context: "background · 2".to_string(), + position: None, target: None, }); let original_toast = app.state.toast.clone(); @@ -1755,6 +1774,172 @@ mod tests { } #[test] + fn notification_show_api_creates_herdr_toast_with_position() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: Some("api workspace".into()), + position: Some(crate::config::ToastHerdrPosition::TopLeft), + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: true, + reason: crate::api::schema::NotificationShowReason::Shown, + } + ); + let toast = app.state.toast.as_ref().expect("api toast"); + assert_eq!(toast.title, "build failed"); + assert_eq!(toast.context, "api workspace"); + assert_eq!( + toast.position, + Some(crate::config::ToastHerdrPosition::TopLeft) + ); + assert!(app.toast_deadline.is_some()); + } + + #[test] + fn notification_show_api_herdr_toast_expires() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: true, + reason: crate::api::schema::NotificationShowReason::Shown, + } + ); + let deadline = app.toast_deadline.expect("api toast deadline"); + assert!(app.handle_scheduled_tasks(deadline, false)); + assert!(app.state.toast.is_none()); + assert!(app.toast_deadline.is_none()); + } + + #[test] + fn notification_show_api_respects_off_delivery() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Off; + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: false, + reason: crate::api::schema::NotificationShowReason::Disabled, + } + ); + assert!(app.state.toast.is_none()); + } + + #[test] + fn notification_show_api_does_not_replace_existing_toast() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::NeedsAttention, + title: "pi needs attention".to_string(), + context: "background · 2".to_string(), + position: None, + target: None, + }); + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: false, + reason: crate::api::schema::NotificationShowReason::Busy, + } + ); + assert_eq!( + app.state.toast.as_ref().map(|toast| toast.title.as_str()), + Some("pi needs attention") + ); + } + + #[test] + fn notification_show_api_is_rate_limited() { + let mut app = test_app(); + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.mark_api_notification_shown(Instant::now()); + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "notify".into(), + method: crate::api::schema::Method::NotificationShow( + crate::api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: crate::api::schema::NotificationShowSound::None, + }, + ), + }); + + let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + crate::api::schema::ResponseResult::NotificationShow { + shown: false, + reason: crate::api::schema::NotificationShowReason::RateLimited, + } + ); + assert!(app.state.toast.is_none()); + } + + #[test] fn internal_event_drain_limits_work_per_tick() { let mut app = test_app(); for i in 0..=APP_EVENT_DRAIN_LIMIT { diff --git a/src/app/runtime.rs b/src/app/runtime.rs index fe971bf5..ca780360 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -205,6 +205,21 @@ impl App { } if self + .state + .next_pending_agent_notification_deadline() + .is_some_and(|deadline| now >= deadline) + { + let previous_toast = self.state.toast.clone(); + let mut deliveries = self.state.drain_due_agent_notifications(now); + if !deliveries.is_empty() { + self.refresh_agent_notification_delivery_contexts(&mut deliveries); + self.emit_delayed_client_local_agent_notifications(&deliveries); + self.sync_toast_deadline(previous_toast); + changed = true; + } + } + + if self .copy_feedback_deadline .is_some_and(|deadline| now >= deadline) { @@ -509,6 +524,7 @@ impl App { include_resize_poll.then_some(self.next_resize_poll), self.config_diagnostic_deadline, self.toast_deadline, + self.state.next_pending_agent_notification_deadline(), self.copy_feedback_deadline, self.next_animation_tick, include_git_refresh diff --git a/src/app/state.rs b/src/app/state.rs index e06fb3f5..494bee0f 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1176,10 +1176,34 @@ pub struct ToastNotification { pub kind: ToastKind, pub title: String, pub context: String, + pub position: Option<crate::config::ToastHerdrPosition>, pub target: Option<ToastTarget>, } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingAgentNotification { + pub pane_id: PaneId, + pub workspace_id: String, + pub agent_label: String, + pub known_agent: Option<crate::detect::Agent>, + pub kind: ToastKind, + pub state: AgentState, + pub deadline: std::time::Instant, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentNotificationDelivery { + pub pane_id: PaneId, + pub workspace_id: String, + pub agent_label: String, + pub known_agent: Option<crate::detect::Agent>, + pub kind: ToastKind, + pub toast: Option<ToastNotification>, + pub client_notification: Option<ToastNotification>, + pub sound: Option<crate::sound::Sound>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CopyFeedback { pub message: String, } @@ -1288,6 +1312,7 @@ pub struct AppState { pub update_dismissed: bool, pub config_diagnostic: Option<String>, pub toast: Option<ToastNotification>, + pub pending_agent_notifications: std::collections::HashMap<PaneId, PendingAgentNotification>, pub copy_feedback: Option<CopyFeedback>, /// Last reported focus state for the outer terminal hosting herdr. /// None means unsupported or not yet reported, which preserves active-pane suppression. @@ -1615,6 +1640,7 @@ impl AppState { update_dismissed: false, config_diagnostic: None, toast: None, + pending_agent_notifications: std::collections::HashMap::new(), copy_feedback: None, outer_terminal_focus: None, prefix_code: KeyCode::Char('b'), |
