diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-01 19:30:58 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-01 19:32:26 +0300 |
| commit | ca4270b31fe93c180f7321cb4006cb5e7307d889 (patch) | |
| tree | 72628e311a1b8e5ebf78c74cbc61d990253dc4c4 /src/app | |
| parent | fb0c9717cfa961a47d63272e622e9e35a001082a (diff) | |
feat: add authoritative pi hook integration
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 287 | ||||
| -rw-r--r-- | src/app/mod.rs | 231 |
2 files changed, 405 insertions, 113 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 02c25d64..04ff19ac 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3,9 +3,10 @@ use tracing::{info, warn}; -use crate::detect::AgentState; +use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; use crate::layout::{find_in_direction, NavDirection, PaneId}; +use crate::pane::EffectiveStateChange; use super::state::{AppState, Mode, ToastKind, ToastNotification}; @@ -43,22 +44,32 @@ fn notification_toast_for_state_change( } } -fn agent_label(agent: crate::detect::Agent) -> &'static str { +fn agent_label(agent: Agent) -> &'static str { match agent { - crate::detect::Agent::Pi => "pi", - crate::detect::Agent::Claude => "claude", - crate::detect::Agent::Codex => "codex", - crate::detect::Agent::Gemini => "gemini", - crate::detect::Agent::Cursor => "cursor", - crate::detect::Agent::Cline => "cline", - crate::detect::Agent::OpenCode => "opencode", - crate::detect::Agent::GithubCopilot => "copilot", - crate::detect::Agent::Kimi => "kimi", - crate::detect::Agent::Droid => "droid", - crate::detect::Agent::Amp => "amp", + Agent::Pi => "pi", + Agent::Claude => "claude", + Agent::Codex => "codex", + Agent::Gemini => "gemini", + Agent::Cursor => "cursor", + Agent::Cline => "cline", + Agent::OpenCode => "opencode", + Agent::GithubCopilot => "copilot", + Agent::Kimi => "kimi", + Agent::Droid => "droid", + Agent::Amp => "amp", } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PaneStateUpdate { + pub pane_id: PaneId, + pub ws_idx: usize, + pub previous_agent: Option<Agent>, + pub previous_state: AgentState, + pub agent: Option<Agent>, + pub state: AgentState, +} + // --------------------------------------------------------------------------- // Workspace operations // --------------------------------------------------------------------------- @@ -193,9 +204,12 @@ impl AppState { // --------------------------------------------------------------------------- impl AppState { - pub fn handle_app_event(&mut self, event: AppEvent) { + pub fn handle_app_event(&mut self, event: AppEvent) -> Vec<PaneStateUpdate> { match event { - AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id), + AppEvent::PaneDied { pane_id } => { + self.handle_pane_died(pane_id); + Vec::new() + } AppEvent::UpdateReady { version } => { self.update_available = Some(version.clone()); self.update_dismissed = true; @@ -204,62 +218,115 @@ impl AppState { title: format!("updated to v{version}"), context: "restart to use it".to_string(), }); + Vec::new() } AppEvent::StateChanged { pane_id, agent, state, - } => { - for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() { - let workspace_name = ws.display_name(); - if let Some(pane) = ws.panes.get_mut(&pane_id) { - let is_active_ws = self.active == Some(ws_idx); - let prev_state = pane.state; - - // Mark unseen when transitioning to Idle in background - if state == AgentState::Idle - && prev_state != AgentState::Idle - && !is_active_ws - { - pane.seen = false; - } - - // Blocked prompts should always make noise; done sounds stay background-only. - if self.sound.allows(agent) { - if let Some(sound) = - notification_sound_for_state_change(is_active_ws, prev_state, state) - { - crate::sound::play(sound); - } - } - - if self.toast_config.enabled { - if let (Some(agent), Some(kind)) = ( - agent, - notification_toast_for_state_change( - is_active_ws, - prev_state, - state, - ), - ) { - let event_text = match kind { - ToastKind::NeedsAttention => "needs attention", - ToastKind::Finished => "finished", - ToastKind::UpdateInstalled => "updated", - }; - self.toast = Some(ToastNotification { - kind, - title: format!("{} {}", agent_label(agent), event_text), - context: format!("{} · {}", workspace_name, ws_idx + 1), - }); - } - } - - pane.detected_agent = agent; - pane.state = state; - break; - } - } + } => self + .update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state)) + .into_iter() + .collect(), + AppEvent::HookStateReported { + pane_id, + source, + agent, + state, + message, + } => self + .update_pane_state(pane_id, |pane| { + pane.set_hook_authority(source, agent, state, message) + }) + .into_iter() + .collect(), + AppEvent::HookAuthorityCleared { pane_id, source } => self + .update_pane_state(pane_id, |pane| pane.clear_hook_authority(source.as_deref())) + .into_iter() + .collect(), + AppEvent::HookAgentReleased { + pane_id, + source, + agent, + } => self + .update_pane_state(pane_id, |pane| pane.release_agent(&source, agent)) + .into_iter() + .collect(), + } + } + + fn update_pane_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate> + where + F: FnOnce(&mut crate::pane::PaneState) -> Option<EffectiveStateChange>, + { + let ws_idx = self + .workspaces + .iter() + .position(|ws| ws.panes.contains_key(&pane_id))?; + let workspace_name = self.workspaces[ws_idx].display_name(); + let change = { + let pane = self.workspaces[ws_idx].panes.get_mut(&pane_id)?; + update(pane)? + }; + self.apply_pane_state_change(ws_idx, pane_id, &workspace_name, change); + Some(PaneStateUpdate { + pane_id, + ws_idx, + previous_agent: change.previous_agent, + previous_state: change.previous_state, + agent: change.agent, + state: change.state, + }) + } + + fn apply_pane_state_change( + &mut self, + ws_idx: usize, + pane_id: PaneId, + workspace_name: &str, + change: EffectiveStateChange, + ) { + let is_active_ws = self.active == Some(ws_idx); + let Some(pane) = self.workspaces[ws_idx].panes.get_mut(&pane_id) else { + return; + }; + + if change.state == AgentState::Idle + && change.previous_state != AgentState::Idle + && !is_active_ws + { + pane.seen = false; + } + + if self.sound.allows(change.agent) { + if let Some(sound) = notification_sound_for_state_change( + is_active_ws, + change.previous_state, + change.state, + ) { + crate::sound::play(sound); + } + } + + if self.toast_config.enabled { + if let (Some(agent), Some(kind)) = ( + change.agent, + notification_toast_for_state_change( + is_active_ws, + change.previous_state, + change.state, + ), + ) { + let event_text = match kind { + ToastKind::NeedsAttention => "needs attention", + ToastKind::Finished => "finished", + ToastKind::UpdateInstalled => "updated", + }; + self.toast = Some(ToastNotification { + kind, + title: format!("{} {}", agent_label(agent), event_text), + context: format!("{} · {}", workspace_name, ws_idx + 1), + }); } } } @@ -474,6 +541,94 @@ mod tests { } #[test] + fn hook_authority_ignores_fallback_state_changes() { + let mut state = app_with_workspaces(&["test"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + }); + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:pi".into(), + agent: Agent::Pi, + state: AgentState::Working, + message: None, + }); + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + }); + + let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); + assert_eq!(pane.fallback_state, AgentState::Idle); + assert_eq!(pane.state, AgentState::Working); + assert!(pane.hook_authority.is_some()); + } + + #[test] + fn hook_authority_clears_when_agent_disappears() { + let mut state = app_with_workspaces(&["test"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + }); + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:pi".into(), + agent: Agent::Pi, + state: AgentState::Working, + message: None, + }); + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: None, + state: AgentState::Unknown, + }); + + let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); + assert!(pane.hook_authority.is_none()); + assert_eq!(pane.detected_agent, None); + assert_eq!(pane.state, AgentState::Unknown); + } + + #[test] + fn hook_agent_release_clears_identity_immediately() { + let mut state = app_with_workspaces(&["test"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + }); + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:pi".into(), + agent: Agent::Pi, + state: AgentState::Working, + message: None, + }); + state.handle_app_event(AppEvent::HookAgentReleased { + pane_id, + source: "herdr:pi".into(), + agent: Agent::Pi, + }); + + let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); + assert!(pane.hook_authority.is_none()); + assert_eq!(pane.detected_agent, None); + assert_eq!(pane.fallback_state, AgentState::Unknown); + assert_eq!(pane.state, AgentState::Unknown); + } + + #[test] fn waiting_sound_plays_even_in_active_workspace() { assert_eq!( notification_sound_for_state_change(true, AgentState::Working, AgentState::Blocked), diff --git a/src/app/mod.rs b/src/app/mod.rs index 7c670586..f6eb9af3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -91,6 +91,9 @@ fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool { | Method::WorkspaceRename(_) | Method::WorkspaceClose(_) | Method::PaneSplit(_) + | Method::PaneReportAgent(_) + | Method::PaneClearAgentAuthority(_) + | Method::PaneReleaseAgent(_) | Method::PaneClose(_) ) } @@ -588,56 +591,76 @@ impl App { } fn handle_internal_event(&mut self, ev: AppEvent) { - match &ev { - AppEvent::PaneDied { pane_id } => { - if let Some((ws_idx, _)) = self.find_pane(*pane_id) { - if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneExited, - data: crate::api::schema::EventData::PaneExited { - pane_id: public_pane_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - } + if let AppEvent::PaneDied { pane_id } = &ev { + if let Some((ws_idx, _)) = self.find_pane(*pane_id) { + if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneExited, + data: crate::api::schema::EventData::PaneExited { + pane_id: public_pane_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); } } - AppEvent::StateChanged { - pane_id, - agent, - state, - } => { - if let Some((ws_idx, pane)) = self.find_pane(*pane_id) { - if let Some(pane_id) = self.public_pane_id(ws_idx, *pane_id) { - let workspace_id = self.public_workspace_id(ws_idx); - if pane.detected_agent != *agent { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentDetected, - data: crate::api::schema::EventData::PaneAgentDetected { - pane_id: pane_id.clone(), - workspace_id: workspace_id.clone(), - agent: agent.map(agent_name), - }, - }); - } - if pane.state != *state { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentStateChanged, - data: crate::api::schema::EventData::PaneAgentStateChanged { - pane_id, - workspace_id, - state: pane_agent_state(*state), - }, - }); - } + } + + let released_agent = if let AppEvent::HookAgentReleased { pane_id, agent, .. } = &ev { + Some((*pane_id, *agent)) + } else { + None + }; + + let previous_toast = self.state.toast.clone(); + let pane_updates = self.state.handle_app_event(ev); + for update in &pane_updates { + self.emit_pane_state_update(update); + } + if let Some((pane_id, agent)) = released_agent { + if pane_updates.iter().any(|update| update.pane_id == pane_id) { + if let Some((ws_idx, _)) = self.find_pane(pane_id) { + if let Some(runtime) = self.state.workspaces[ws_idx].runtimes.get(&pane_id) { + runtime.begin_graceful_release(agent); } } } - AppEvent::UpdateReady { .. } => {} } + self.sync_toast_deadline(previous_toast); + } - let previous_toast = self.state.toast.clone(); - self.state.handle_app_event(ev); + fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) { + let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { + return; + }; + let workspace_id = self.public_workspace_id(update.ws_idx); + + if update.previous_agent != update.agent { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneAgentDetected, + data: crate::api::schema::EventData::PaneAgentDetected { + pane_id: pane_id.clone(), + workspace_id: workspace_id.clone(), + agent: update.agent.map(agent_name), + }, + }); + } + + if update.previous_state != update.state { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneAgentStateChanged, + data: crate::api::schema::EventData::PaneAgentStateChanged { + pane_id, + workspace_id, + state: pane_agent_state(update.state), + }, + }); + } + } + + fn sync_toast_deadline( + &mut self, + previous_toast: Option<crate::app::state::ToastNotification>, + ) { if self.state.toast != previous_toast { self.toast_deadline = self.state.toast.as_ref().map(|toast| { let duration = match toast.kind { @@ -716,10 +739,14 @@ impl App { fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> { if let Some(rest) = id.strip_prefix("p_") { - let (ws_raw, pane_raw) = rest.split_once('_')?; - let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?; - let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?); - return Some((ws_idx, pane_id)); + if let Some((ws_raw, pane_raw)) = rest.split_once('_') { + let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?; + let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?); + return Some((ws_idx, pane_id)); + } + + let pane_id = crate::layout::PaneId::from_raw(rest.parse::<u32>().ok()?); + return self.find_pane(pane_id).map(|(ws_idx, _)| (ws_idx, pane_id)); } let (ws_raw, pane_number_raw) = id.split_once('-')?; @@ -1064,6 +1091,90 @@ impl App { }, } } + Method::PaneReportAgent(params) => { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "pane_not_found".into(), + message: format!("pane {} not found", params.pane_id), + }, + }) + .unwrap(); + }; + let Some(agent) = parse_agent_name(¶ms.agent) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "invalid_agent".into(), + message: format!("unsupported agent {}", params.agent), + }, + }) + .unwrap(); + }; + self.handle_internal_event(crate::events::AppEvent::HookStateReported { + pane_id, + source: params.source, + agent, + state: detect_state_from_api(params.state), + message: params.message, + }); + SuccessResponse { + id: request.id, + result: ResponseResult::Ok {}, + } + } + Method::PaneClearAgentAuthority(params) => { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "pane_not_found".into(), + message: format!("pane {} not found", params.pane_id), + }, + }) + .unwrap(); + }; + self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared { + pane_id, + source: params.source, + }); + SuccessResponse { + id: request.id, + result: ResponseResult::Ok {}, + } + } + Method::PaneReleaseAgent(params) => { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "pane_not_found".into(), + message: format!("pane {} not found", params.pane_id), + }, + }) + .unwrap(); + }; + let Some(agent) = parse_agent_name(¶ms.agent) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "invalid_agent".into(), + message: format!("unsupported agent {}", params.agent), + }, + }) + .unwrap(); + }; + self.handle_internal_event(crate::events::AppEvent::HookAgentReleased { + pane_id, + source: params.source, + agent, + }); + SuccessResponse { + id: request.id, + result: ResponseResult::Ok {}, + } + } Method::PaneSendText(params) => { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return serde_json::to_string(&ErrorResponse { @@ -1483,6 +1594,15 @@ fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { } } +fn detect_state_from_api(state: crate::api::schema::PaneAgentState) -> crate::detect::AgentState { + match state { + crate::api::schema::PaneAgentState::Idle => crate::detect::AgentState::Idle, + crate::api::schema::PaneAgentState::Working => crate::detect::AgentState::Working, + crate::api::schema::PaneAgentState::Blocked => crate::detect::AgentState::Blocked, + crate::api::schema::PaneAgentState::Unknown => crate::detect::AgentState::Unknown, + } +} + fn pane_agent_state(state: crate::detect::AgentState) -> crate::api::schema::PaneAgentState { match state { crate::detect::AgentState::Idle => crate::api::schema::PaneAgentState::Idle, @@ -1492,6 +1612,23 @@ fn pane_agent_state(state: crate::detect::AgentState) -> crate::api::schema::Pan } } +fn parse_agent_name(agent: &str) -> Option<crate::detect::Agent> { + match agent { + "pi" => Some(crate::detect::Agent::Pi), + "claude" => Some(crate::detect::Agent::Claude), + "codex" => Some(crate::detect::Agent::Codex), + "gemini" => Some(crate::detect::Agent::Gemini), + "cursor" => Some(crate::detect::Agent::Cursor), + "cline" => Some(crate::detect::Agent::Cline), + "opencode" => Some(crate::detect::Agent::OpenCode), + "copilot" => Some(crate::detect::Agent::GithubCopilot), + "kimi" => Some(crate::detect::Agent::Kimi), + "droid" => Some(crate::detect::Agent::Droid), + "amp" => Some(crate::detect::Agent::Amp), + _ => None, + } +} + fn agent_name(agent: crate::detect::Agent) -> String { match agent { crate::detect::Agent::Pi => "pi", |
