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/actions.rs | |
| parent | a7cd7806f2fa0251e2dca41ffec61ceafb6f0e18 (diff) | |
feat: use pty-first agent detection
Diffstat (limited to 'src/app/actions.rs')
| -rw-r--r-- | src/app/actions.rs | 245 |
1 files changed, 210 insertions, 35 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); |
