aboutsummaryrefslogtreecommitdiff
path: root/src/app
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/app
parent048d8a29195d580014b24ee3fc7e89ac6bdfee8a (diff)
feat: improve toast ergonomics
refs #486
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs405
-rw-r--r--src/app/api.rs301
-rw-r--r--src/app/input/mouse.rs2
-rw-r--r--src/app/input/navigate.rs4
-rw-r--r--src/app/mod.rs185
-rw-r--r--src/app/runtime.rs16
-rw-r--r--src/app/state.rs26
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(&params.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'),