diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-08 20:55:51 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-09 04:26:19 +0300 |
| commit | ee51c5e5fbaea694e65bb0a4908f75bcaf9707d1 (patch) | |
| tree | f566d2a5921d4112602a7abcffb5cf6eb04d5f24 /src/app | |
| parent | a7cd7806f2fa0251e2dca41ffec61ceafb6f0e18 (diff) | |
feat: use pty-first agent detection
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 245 | ||||
| -rw-r--r-- | src/app/api.rs | 136 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 1 | ||||
| -rw-r--r-- | src/app/mod.rs | 2 |
4 files changed, 289 insertions, 95 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 371457a8..249d0e64 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -22,6 +22,28 @@ fn is_background_completion_transition(prev_state: AgentState, new_state: AgentS && matches!(prev_state, AgentState::Working | AgentState::Blocked) } +fn is_completion_transition(change: &EffectiveStateChange) -> bool { + is_completion_transition_parts( + change.previous_state, + change.state, + change.previous_agent_label.as_deref(), + change.agent_label.as_deref(), + ) +} + +pub fn is_completion_transition_parts( + previous_state: AgentState, + state: AgentState, + previous_agent_label: Option<&str>, + agent_label: Option<&str>, +) -> bool { + is_background_completion_transition(previous_state, state) + || (previous_state == AgentState::Unknown + && state == AgentState::Idle + && previous_agent_label.is_some() + && previous_agent_label == agent_label) +} + pub fn active_tab_suppresses_notifications( is_active_tab: bool, outer_terminal_focus: Option<bool>, @@ -29,6 +51,7 @@ pub fn active_tab_suppresses_notifications( is_active_tab && outer_terminal_focus != Some(false) } +#[cfg(test)] pub fn notification_sound_for_state_change( suppress_active_tab_notifications: bool, prev_state: AgentState, @@ -50,10 +73,58 @@ pub fn notification_sound_for_state_change( } } -pub fn notification_toast_for_state_change( +pub fn notification_sound_for_state_change_with_agent_labels( suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, + previous_agent_label: Option<&str>, + agent_label: Option<&str>, +) -> Option<crate::sound::Sound> { + if new_state == prev_state { + return None; + } + + match new_state { + AgentState::Blocked => Some(crate::sound::Sound::Request), + AgentState::Idle + if is_completion_transition_parts( + prev_state, + new_state, + previous_agent_label, + agent_label, + ) && !suppress_active_tab_notifications => + { + Some(crate::sound::Sound::Done) + } + _ => None, + } +} + +fn notification_sound_for_effective_state_change( + suppress_active_tab_notifications: bool, + change: &EffectiveStateChange, +) -> Option<crate::sound::Sound> { + if change.state == change.previous_state { + return None; + } + + match change.state { + AgentState::Blocked => Some(crate::sound::Sound::Request), + AgentState::Idle + if is_completion_transition(change) && !suppress_active_tab_notifications => + { + Some(crate::sound::Sound::Done) + } + _ => None, + } +} + +pub fn notification_toast_for_state_change_with_agent_labels( + suppress_active_tab_notifications: bool, + prev_state: AgentState, + new_state: AgentState, + previous_agent_label: Option<&str>, + agent_label: Option<&str>, ) -> Option<ToastKind> { if suppress_active_tab_notifications || new_state == prev_state { return None; @@ -61,13 +132,52 @@ pub fn notification_toast_for_state_change( match new_state { AgentState::Blocked => Some(ToastKind::NeedsAttention), - AgentState::Idle if is_background_completion_transition(prev_state, new_state) => { + AgentState::Idle + if is_completion_transition_parts( + prev_state, + new_state, + previous_agent_label, + agent_label, + ) => + { Some(ToastKind::Finished) } _ => None, } } +fn notification_toast_for_effective_state_change( + suppress_active_tab_notifications: bool, + change: &EffectiveStateChange, +) -> Option<ToastKind> { + if suppress_active_tab_notifications || change.state == change.previous_state { + return None; + } + + match change.state { + AgentState::Blocked => Some(ToastKind::NeedsAttention), + AgentState::Idle if is_completion_transition(change) => Some(ToastKind::Finished), + _ => None, + } +} + +pub fn notification_toast_for_pane_state_update( + suppress_active_tab_notifications: bool, + update: &PaneStateUpdate, +) -> Option<ToastKind> { + if suppress_active_tab_notifications || update.state == update.previous_state { + return None; + } + + notification_toast_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + update.previous_state, + update.state, + update.previous_agent_label.as_deref(), + update.agent_label.as_deref(), + ) +} + fn toast_agent_label(agent_label: &str) -> &str { agent_label } @@ -2207,7 +2317,6 @@ impl AppState { agent, state, visible_blocker, - visible_idle, visible_working, process_exited, observed_at, @@ -2217,7 +2326,7 @@ impl AppState { agent, state, visible_blocker, - visible_idle, + false, visible_working, process_exited, observed_at, @@ -2405,6 +2514,27 @@ impl AppState { Some(update) } + pub(crate) fn publish_pane_process_exit_if_agent( + &mut self, + pane_id: PaneId, + ) -> Option<PaneStateUpdate> { + let observed_at = std::time::Instant::now(); + self.update_terminal_state(pane_id, |terminal| { + let agent = terminal + .effective_known_agent() + .or(terminal.detected_agent)?; + Some(terminal.set_detected_state_with_screen_signals_at( + Some(agent), + AgentState::Idle, + false, + false, + false, + true, + observed_at, + )) + }) + } + fn apply_pane_state_change( &mut self, ws_idx: usize, @@ -2421,7 +2551,7 @@ impl AppState { if change.state != AgentState::Idle { pane.seen = true; - } else if is_background_completion_transition(change.previous_state, change.state) { + } else if is_completion_transition(change) { pane.seen = suppress_active_tab_notifications; } let seen = pane.seen; @@ -2445,15 +2575,13 @@ impl AppState { 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( + let client_notification_kind = notification_toast_for_effective_state_change( suppress_active_tab_notifications, - change.previous_state, - change.state, + change, ); - let sound = notification_sound_for_state_change( + let sound = notification_sound_for_effective_state_change( suppress_active_tab_notifications, - change.previous_state, - change.state, + change, ); if client_notification_kind.is_none() && sound.is_none() { return None; @@ -3834,7 +3962,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3854,6 +3981,7 @@ mod tests { #[test] fn state_changed_idle_in_background_marks_unseen() { let mut state = app_with_workspaces(&["active", "background"]); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.active = Some(0); let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); @@ -3872,7 +4000,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3880,6 +4007,10 @@ mod tests { let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); assert!(!pane.seen); + assert!(matches!( + state.toast.as_ref().map(|toast| toast.kind), + Some(ToastKind::Finished) + )); } #[test] @@ -3902,7 +4033,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3925,7 +4055,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3936,6 +4065,36 @@ mod tests { } #[test] + fn idle_after_known_unknown_agent_in_background_marks_done() { + let mut state = app_with_workspaces(&["active", "background"]); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.active = Some(0); + 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::Unknown, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); + assert!(!pane.seen); + } + + #[test] fn waiting_sound_plays_even_in_active_workspace() { assert_eq!( notification_sound_for_state_change(true, AgentState::Working, AgentState::Blocked), @@ -3971,7 +4130,6 @@ mod tests { 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(), @@ -3996,7 +4154,6 @@ mod tests { 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(), @@ -4029,7 +4186,6 @@ mod tests { 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(), @@ -4041,7 +4197,6 @@ mod tests { 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(), @@ -4065,7 +4220,6 @@ mod tests { 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(), @@ -4091,7 +4245,6 @@ mod tests { 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(), @@ -4119,7 +4272,6 @@ mod tests { 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(), @@ -4176,7 +4328,6 @@ mod tests { 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(), @@ -4196,7 +4347,6 @@ mod tests { agent: Some(Agent::Codex), state: AgentState::Blocked, visible_blocker: true, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4227,7 +4377,6 @@ mod tests { agent: Some(Agent::Claude), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4252,7 +4401,6 @@ mod tests { agent: Some(Agent::Claude), state: AgentState::Idle, visible_blocker: false, - visible_idle: true, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4279,7 +4427,6 @@ mod tests { agent: Some(Agent::Claude), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: true, process_exited: false, observed_at: std::time::Instant::now(), @@ -4298,7 +4445,7 @@ mod tests { } #[test] - fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() { + fn hidden_custom_session_ref_only_update_marks_session_dirty_without_visible_update() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let test_dir = std::env::current_dir().unwrap(); @@ -4307,7 +4454,7 @@ mod tests { let first_updates = state.handle_app_event(AppEvent::HookStateReported { pane_id, - source: "herdr:pi".into(), + source: "custom:pi".into(), agent_label: "pi".into(), state: AgentState::Working, message: None, @@ -4320,7 +4467,7 @@ mod tests { let second_updates = state.handle_app_event(AppEvent::HookStateReported { pane_id, - source: "herdr:pi".into(), + source: "custom:pi".into(), agent_label: "pi".into(), state: AgentState::Working, message: None, @@ -4377,7 +4524,6 @@ mod tests { agent: Some(Agent::Droid), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4407,7 +4553,6 @@ mod tests { 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(), @@ -4434,7 +4579,6 @@ mod tests { 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(), @@ -4458,7 +4602,6 @@ mod tests { 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(), @@ -4480,7 +4623,6 @@ mod tests { 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(), @@ -4678,6 +4820,39 @@ mod tests { } #[test] + fn pane_process_exit_publish_marks_agent_idle_before_pane_removal() { + let mut state = app_with_workspaces(&["active", "background"]); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.active = Some(1); + state.ensure_test_terminals(); + let pane_id = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Pi), AgentState::Working); + assert_eq!( + state.terminals.get(&terminal_id).unwrap().state, + AgentState::Working + ); + + let update = state + .publish_pane_process_exit_if_agent(pane_id) + .expect("process exit update"); + + assert!(!state.pane_is_in_active_tab(update.ws_idx, pane_id)); + assert_eq!(update.previous_state, AgentState::Working); + assert_eq!(update.state, AgentState::Idle); + assert_eq!(update.agent_label.as_deref(), Some("pi")); + assert_eq!(update.known_agent, Some(Agent::Pi)); + assert!(matches!( + state.toast.as_ref().map(|toast| toast.kind), + Some(ToastKind::Finished) + )); + } + + #[test] fn close_pane_removes_unattached_terminal_state() { let mut state = app_with_workspaces(&["test"]); let pane_id = state.workspaces[0].test_split(Direction::Horizontal); diff --git a/src/app/api.rs b/src/app/api.rs index 166ea789..76d9c537 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -67,6 +67,12 @@ impl App { } if let AppEvent::PaneDied { pane_id } = &ev { + let previous_toast = self.state.toast.clone(); + if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) { + self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); + self.emit_pane_state_update(&update); + self.emit_terminal_or_system_agent_notifications(std::slice::from_ref(&update)); + } if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell && self.respawn_shell_for_launch_pane(*pane_id) { @@ -161,57 +167,7 @@ impl App { let instruction = crate::update::update_install_instruction(&install_command); let _ = notify(&format!("v{version} available"), Some(&instruction)); } else if self.state.toast_config.delay_seconds == 0 { - for update in &pane_updates { - let is_active_tab = self - .state - .pane_is_in_active_tab(update.ws_idx, update.pane_id); - let suppress_active_tab_notifications = - crate::app::actions::active_tab_suppresses_notifications( - is_active_tab, - self.state.outer_terminal_focus, - ); - let Some(kind) = crate::app::actions::notification_toast_for_state_change( - suppress_active_tab_notifications, - update.previous_state, - update.state, - ) else { - continue; - }; - let Some(ws) = self.state.workspaces.get(update.ws_idx) else { - continue; - }; - let Some(pane) = ws - .tabs - .iter() - .find_map(|tab| tab.panes.get(&update.pane_id)) - else { - continue; - }; - let Some(agent_label) = self - .state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label()) - else { - continue; - }; - let event_text = match kind { - ToastKind::NeedsAttention => "needs attention", - ToastKind::Finished => "finished", - ToastKind::UpdateInstalled => "updated", - }; - let workspace_label = - ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); - let _ = notify( - &format!("{} {}", agent_label, event_text), - Some(&crate::app::actions::notification_context( - ws, - &workspace_label, - update.ws_idx, - update.pane_id, - )), - ); - } + self.emit_terminal_or_system_agent_notifications(&pane_updates); } } @@ -407,6 +363,78 @@ impl App { } } + fn emit_terminal_or_system_agent_notifications( + &self, + pane_updates: &[crate::app::actions::PaneStateUpdate], + ) { + if !self.local_terminal_notifications + || self.state.toast_config.delay_seconds != 0 + || !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, + _ => return, + }; + + for update in pane_updates { + let is_active_tab = self + .state + .pane_is_in_active_tab(update.ws_idx, update.pane_id); + let suppress_active_tab_notifications = + crate::app::actions::active_tab_suppresses_notifications( + is_active_tab, + self.state.outer_terminal_focus, + ); + let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( + suppress_active_tab_notifications, + update, + ) else { + continue; + }; + let Some(ws) = self.state.workspaces.get(update.ws_idx) else { + continue; + }; + let Some(pane) = ws + .tabs + .iter() + .find_map(|tab| tab.panes.get(&update.pane_id)) + else { + continue; + }; + let Some(agent_label) = self + .state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label()) + else { + continue; + }; + let event_text = match kind { + ToastKind::NeedsAttention => "needs attention", + ToastKind::Finished => "finished", + ToastKind::UpdateInstalled => "updated", + }; + let workspace_label = + ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); + let _ = notify( + &format!("{} {}", agent_label, event_text), + Some(&crate::app::actions::notification_context( + ws, + &workspace_label, + update.ws_idx, + update.pane_id, + )), + ); + } + } + pub(crate) fn sync_toast_deadline( &mut self, previous_toast: Option<crate::app::state::ToastNotification>, @@ -891,7 +919,6 @@ mod tests { 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(), @@ -901,7 +928,6 @@ mod tests { 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(), @@ -984,7 +1010,6 @@ mod tests { 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(), @@ -994,7 +1019,6 @@ mod tests { 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(), @@ -1096,7 +1120,6 @@ mod tests { 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(), @@ -1117,7 +1140,6 @@ mod tests { 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(), diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index a682d7ac..a241dadb 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -2260,7 +2260,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 3a038dee..936b082f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3621,7 +3621,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3646,7 +3645,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), |
