aboutsummaryrefslogtreecommitdiff
path: root/src/app/api.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api.rs')
-rw-r--r--src/app/api.rs301
1 files changed, 299 insertions, 2 deletions
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,