aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-01 19:30:58 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-01 19:32:26 +0300
commitca4270b31fe93c180f7321cb4006cb5e7307d889 (patch)
tree72628e311a1b8e5ebf78c74cbc61d990253dc4c4 /src/app
parentfb0c9717cfa961a47d63272e622e9e35a001082a (diff)
feat: add authoritative pi hook integration
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs287
-rw-r--r--src/app/mod.rs231
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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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",