diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-16 22:12:44 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-16 22:17:09 +0300 |
| commit | 0b831c86274486af9f9f7052c2534374b498bb3a (patch) | |
| tree | 3e8130be85cb387c8c3e57591617c4c7be0a26ec /src | |
| parent | 318e6a3033b61082d9c330c79ef584cf37e6c6d0 (diff) | |
feat: allow hook-reported custom agent labels
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 77 | ||||
| -rw-r--r-- | src/app/api.rs | 30 | ||||
| -rw-r--r-- | src/app/mod.rs | 47 | ||||
| -rw-r--r-- | src/detect.rs | 53 | ||||
| -rw-r--r-- | src/events.rs | 5 | ||||
| -rw-r--r-- | src/pane.rs | 126 | ||||
| -rw-r--r-- | src/workspace.rs | 50 |
7 files changed, 244 insertions, 144 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index ffb41ba1..3617bb07 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -53,20 +53,8 @@ fn notification_toast_for_state_change( } } -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", - } +fn toast_agent_label(agent_label: &str) -> &str { + agent_label } fn notification_context( @@ -84,13 +72,15 @@ fn notification_context( context } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct PaneStateUpdate { pub pane_id: PaneId, pub ws_idx: usize, - pub previous_agent: Option<Agent>, + pub previous_agent_label: Option<String>, + pub previous_known_agent: Option<Agent>, pub previous_state: AgentState, - pub agent: Option<Agent>, + pub agent_label: Option<String>, + pub known_agent: Option<Agent>, pub state: AgentState, } @@ -497,12 +487,12 @@ impl AppState { AppEvent::HookStateReported { pane_id, source, - agent, + agent_label, state, message, } => self .update_pane_state(pane_id, |pane| { - pane.set_hook_authority(source, agent, state, message) + pane.set_hook_authority(source, agent_label, state, message) }) .into_iter() .collect(), @@ -513,9 +503,10 @@ impl AppState { AppEvent::HookAgentReleased { pane_id, source, - agent, + agent_label, + .. } => self - .update_pane_state(pane_id, |pane| pane.release_agent(&source, agent)) + .update_pane_state(pane_id, |pane| pane.release_agent(&source, &agent_label)) .into_iter() .collect(), // Intercepted in App::handle_internal_event before reaching this @@ -539,22 +530,25 @@ impl AppState { .find_map(|tab| tab.panes.get_mut(&pane_id))?; update(pane)? }; - self.apply_pane_state_change(ws_idx, pane_id, change); - Some(PaneStateUpdate { + let update = PaneStateUpdate { pane_id, ws_idx, - previous_agent: change.previous_agent, + previous_agent_label: change.previous_agent_label.clone(), + previous_known_agent: change.previous_known_agent, previous_state: change.previous_state, - agent: change.agent, + agent_label: change.agent_label.clone(), + known_agent: change.known_agent, state: change.state, - }) + }; + self.apply_pane_state_change(ws_idx, pane_id, &change); + Some(update) } fn apply_pane_state_change( &mut self, ws_idx: usize, pane_id: PaneId, - change: EffectiveStateChange, + change: &EffectiveStateChange, ) { let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); let Some(pane) = self.workspaces[ws_idx] @@ -571,7 +565,7 @@ impl AppState { pane.seen = false; } - if self.sound.allows(change.agent) { + if self.sound.allows(change.known_agent) { if let Some(sound) = notification_sound_for_state_change( is_active_tab, change.previous_state, @@ -582,8 +576,8 @@ impl AppState { } if self.toast_config.enabled { - if let (Some(agent), Some(kind)) = ( - change.agent, + if let (Some(agent_label), Some(kind)) = ( + change.agent_label.as_deref(), notification_toast_for_state_change( is_active_tab, change.previous_state, @@ -598,7 +592,7 @@ impl AppState { let context = notification_context(&self.workspaces[ws_idx], ws_idx, pane_id); self.toast = Some(ToastNotification { kind, - title: format!("{} {}", agent_label(agent), event_text), + title: format!("{} {}", toast_agent_label(agent_label), event_text), context, }); } @@ -927,6 +921,27 @@ mod tests { } #[test] + fn hook_reported_unknown_agent_sets_toast_title_from_label() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + state.toast_config.enabled = true; + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::HookStateReported { + pane_id: bg_pane_id, + source: "custom:hermes".into(), + agent_label: "hermes".into(), + state: AgentState::Blocked, + message: None, + }); + + let toast = state.toast.as_ref().unwrap(); + assert_eq!(toast.kind, ToastKind::NeedsAttention); + assert_eq!(toast.title, "hermes needs attention"); + assert_eq!(toast.context, "background · 2"); + } + + #[test] fn background_idle_sets_finished_toast() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); diff --git a/src/app/api.rs b/src/app/api.rs index 1e969b64..2ed224c7 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1,10 +1,21 @@ //! API request handling and response building for [`App`]. use super::{ - agent_name, detect_state_from_api, encode_api_keys, encode_api_text, pane_agent_status, - parse_agent_name, tab_attention_priority, App, Mode, + detect_state_from_api, encode_api_keys, encode_api_text, pane_agent_status, + tab_attention_priority, App, Mode, }; +fn normalize_reported_agent_label(agent: &str) -> Option<String> { + let trimmed = agent.trim(); + if trimmed.is_empty() { + return None; + } + if let Some(agent) = crate::detect::parse_agent_label(trimmed) { + return Some(crate::detect::agent_label(agent).to_string()); + } + Some(trimmed.to_string()) +} + pub(super) fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool { use crate::api::schema::Method; @@ -775,12 +786,12 @@ impl App { }) .unwrap(); }; - let Some(agent) = parse_agent_name(¶ms.agent) else { + let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { code: "invalid_agent".into(), - message: format!("unsupported agent {}", params.agent), + message: "agent label must not be empty".into(), }, }) .unwrap(); @@ -788,7 +799,7 @@ impl App { self.handle_internal_event(crate::events::AppEvent::HookStateReported { pane_id, source: params.source, - agent, + agent_label, state: detect_state_from_api(params.state), message: params.message, }); @@ -828,12 +839,12 @@ impl App { }) .unwrap(); }; - let Some(agent) = parse_agent_name(¶ms.agent) else { + let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { code: "invalid_agent".into(), - message: format!("unsupported agent {}", params.agent), + message: "agent label must not be empty".into(), }, }) .unwrap(); @@ -841,7 +852,8 @@ impl App { self.handle_internal_event(crate::events::AppEvent::HookAgentReleased { pane_id, source: params.source, - agent, + known_agent: crate::detect::parse_agent_label(&agent_label), + agent_label, }); SuccessResponse { id: request.id, @@ -1185,7 +1197,7 @@ impl App { cwd: runtime .and_then(|rt| rt.cwd()) .map(|cwd| cwd.display().to_string()), - agent: pane.detected_agent.map(agent_name), + agent: pane.effective_agent_label().map(str::to_string), agent_status: pane_agent_status(pane.state, pane.seen), revision: 0, }) diff --git a/src/app/mod.rs b/src/app/mod.rs index 11a034f9..7f821da2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -795,8 +795,13 @@ impl App { } } - let released_agent = if let AppEvent::HookAgentReleased { pane_id, agent, .. } = &ev { - Some((*pane_id, *agent)) + let released_agent = if let AppEvent::HookAgentReleased { + pane_id, + known_agent, + .. + } = &ev + { + known_agent.map(|agent| (*pane_id, agent)) } else { None }; @@ -827,13 +832,13 @@ impl App { }; let workspace_id = self.public_workspace_id(update.ws_idx); - if update.previous_agent != update.agent { + if update.previous_agent_label != update.agent_label { 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), + agent: update.agent_label.clone(), }, }); } @@ -1275,40 +1280,6 @@ fn pane_agent_status( } } -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", - 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", - } - .to_string() -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/detect.rs b/src/detect.rs index 178fe0a5..df5bbae4 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -32,6 +32,40 @@ pub enum Agent { Amp, } +pub fn agent_label(agent: Agent) -> &'static str { + match agent { + 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", + } +} + +pub fn parse_agent_label(agent: &str) -> Option<Agent> { + let name = agent.trim().to_lowercase(); + match name.as_str() { + "pi" => Some(Agent::Pi), + "claude" | "claude-code" => Some(Agent::Claude), + "codex" => Some(Agent::Codex), + "gemini" => Some(Agent::Gemini), + "cursor" => Some(Agent::Cursor), + "cline" => Some(Agent::Cline), + "opencode" | "open-code" => Some(Agent::OpenCode), + "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot), + "kimi" => Some(Agent::Kimi), + "droid" => Some(Agent::Droid), + "amp" | "amp-local" => Some(Agent::Amp), + _ => None, + } +} + /// Identify which agent is running from the process name. /// Returns `None` for plain shells or unrecognized programs. pub fn identify_agent(process_name: &str) -> Option<Agent> { @@ -624,6 +658,25 @@ mod tests { } #[test] + fn parse_known_agent_labels() { + assert_eq!(parse_agent_label("pi"), Some(Agent::Pi)); + assert_eq!(parse_agent_label("claude"), Some(Agent::Claude)); + assert_eq!(parse_agent_label("copilot"), Some(Agent::GithubCopilot)); + assert_eq!( + parse_agent_label("github-copilot"), + Some(Agent::GithubCopilot) + ); + assert_eq!(parse_agent_label("amp-local"), Some(Agent::Amp)); + } + + #[test] + fn agent_labels_use_display_names() { + assert_eq!(agent_label(Agent::Pi), "pi"); + assert_eq!(agent_label(Agent::GithubCopilot), "copilot"); + assert_eq!(agent_label(Agent::OpenCode), "opencode"); + } + + #[test] fn identify_unknown_processes() { assert_eq!(identify_agent("bash"), None); assert_eq!(identify_agent("zsh"), None); diff --git a/src/events.rs b/src/events.rs index 52efef61..dd433d55 100644 --- a/src/events.rs +++ b/src/events.rs @@ -21,7 +21,7 @@ pub enum AppEvent { HookStateReported { pane_id: PaneId, source: String, - agent: Agent, + agent_label: String, state: AgentState, message: Option<String>, }, @@ -34,7 +34,8 @@ pub enum AppEvent { HookAgentReleased { pane_id: PaneId, source: String, - agent: Agent, + agent_label: String, + known_agent: Option<Agent>, }, /// A new version was downloaded and installed. Restart to use it. UpdateReady { version: String }, diff --git a/src/pane.rs b/src/pane.rs index 5e71e6c9..fb34b6ab 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -105,16 +105,18 @@ fn stabilize_agent_state( #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookAuthority { pub source: String, - pub agent: Agent, + pub agent_label: String, pub state: AgentState, pub message: Option<String>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveStateChange { - pub previous_agent: Option<Agent>, + pub previous_agent_label: Option<String>, + pub previous_known_agent: Option<Agent>, pub previous_state: AgentState, - pub agent: Option<Agent>, + pub agent_label: Option<String>, + pub known_agent: Option<Agent>, pub state: AgentState, } @@ -146,40 +148,36 @@ impl PaneState { agent: Option<Agent>, fallback_state: AgentState, ) -> Option<EffectiveStateChange> { - let previous_agent = self.detected_agent; + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); let previous_state = self.state; self.detected_agent = agent; self.fallback_state = fallback_state; - if self - .hook_authority - .as_ref() - .is_some_and(|authority| Some(authority.agent) != self.detected_agent) - { - self.hook_authority = None; - } - self.recompute_effective_state(previous_agent, previous_state) + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } pub fn set_hook_authority( &mut self, source: String, - agent: Agent, + agent_label: String, state: AgentState, message: Option<String>, ) -> Option<EffectiveStateChange> { - let previous_agent = self.detected_agent; + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); let previous_state = self.state; self.hook_authority = Some(HookAuthority { source, - agent, + agent_label, state, message, }); - self.recompute_effective_state(previous_agent, previous_state) + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } pub fn clear_hook_authority(&mut self, source: Option<&str>) -> Option<EffectiveStateChange> { - let previous_agent = self.detected_agent; + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); let previous_state = self.state; let should_clear = self .hook_authority @@ -189,51 +187,75 @@ impl PaneState { return None; } self.hook_authority = None; - self.recompute_effective_state(previous_agent, previous_state) + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } - pub fn release_agent(&mut self, source: &str, agent: Agent) -> Option<EffectiveStateChange> { - if self.detected_agent != Some(agent) { + pub fn release_agent( + &mut self, + source: &str, + agent_label: &str, + ) -> Option<EffectiveStateChange> { + let Some(current_agent_label) = self.effective_agent_label() else { + return None; + }; + if current_agent_label != agent_label { return None; } - if self - .hook_authority - .as_ref() - .is_some_and(|authority| authority.agent != agent || authority.source != source) - { + if self.hook_authority.as_ref().is_some_and(|authority| { + authority.agent_label != agent_label || authority.source != source + }) { return None; } - let previous_agent = self.detected_agent; + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); let previous_state = self.state; self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.hook_authority = None; - self.recompute_effective_state(previous_agent, previous_state) + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + } + + pub fn effective_agent_label(&self) -> Option<&str> { + self.hook_authority + .as_ref() + .map(|authority| authority.agent_label.as_str()) + .or_else(|| self.detected_agent.map(crate::detect::agent_label)) + } + + pub fn effective_known_agent(&self) -> Option<Agent> { + if let Some(authority) = &self.hook_authority { + return crate::detect::parse_agent_label(&authority.agent_label); + } + self.detected_agent } fn recompute_effective_state( &mut self, - previous_agent: Option<Agent>, + previous_agent_label: Option<String>, + previous_known_agent: Option<Agent>, previous_state: AgentState, ) -> Option<EffectiveStateChange> { let state = self .hook_authority .as_ref() - .filter(|authority| Some(authority.agent) == self.detected_agent) .map(|authority| authority.state) .unwrap_or(self.fallback_state); + let agent_label = self.effective_agent_label().map(str::to_string); + let known_agent = self.effective_known_agent(); - if previous_agent == self.detected_agent && previous_state == state { + if previous_agent_label == agent_label && previous_state == state { return None; } self.state = state; Some(EffectiveStateChange { - previous_agent, + previous_agent_label, + previous_known_agent, previous_state, - agent: self.detected_agent, + agent_label, + known_agent, state, }) } @@ -3312,33 +3334,57 @@ mod tests { fn hook_authority_overrides_fallback_for_same_agent() { let mut pane = PaneState::new(); pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); - pane.set_hook_authority("herdr:pi".into(), Agent::Pi, AgentState::Working, None); + pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None); assert_eq!(pane.detected_agent, Some(Agent::Pi)); assert_eq!(pane.fallback_state, AgentState::Idle); + assert_eq!(pane.effective_agent_label(), Some("pi")); assert_eq!(pane.state, AgentState::Working); } #[test] - fn hook_authority_clears_when_detected_agent_changes() { + fn hook_authority_can_override_with_unknown_agent_label() { let mut pane = PaneState::new(); pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); - pane.set_hook_authority("herdr:pi".into(), Agent::Pi, AgentState::Working, None); + pane.set_hook_authority( + "herdr:custom".into(), + "hermes".into(), + AgentState::Working, + None, + ); + + assert_eq!(pane.detected_agent, Some(Agent::Pi)); + assert_eq!(pane.effective_agent_label(), Some("hermes")); + assert_eq!(pane.effective_known_agent(), None); + assert_eq!(pane.state, AgentState::Working); + } + + #[test] + fn hook_authority_survives_detected_agent_changes() { + let mut pane = PaneState::new(); + pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); + pane.set_hook_authority( + "herdr:custom".into(), + "hermes".into(), + AgentState::Working, + None, + ); pane.set_detected_state(None, AgentState::Unknown); - assert!(pane.hook_authority.is_none()); + assert!(pane.hook_authority.is_some()); assert_eq!(pane.detected_agent, None); - assert_eq!(pane.state, AgentState::Unknown); + assert_eq!(pane.effective_agent_label(), Some("hermes")); + assert_eq!(pane.state, AgentState::Working); } #[test] fn release_agent_clears_identity_immediately() { let mut pane = PaneState::new(); pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); - pane.set_hook_authority("herdr:pi".into(), Agent::Pi, AgentState::Working, None); + pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None); - pane.release_agent("herdr:pi", Agent::Pi); + pane.release_agent("herdr:pi", "pi"); assert!(pane.hook_authority.is_none()); assert_eq!(pane.detected_agent, None); diff --git a/src/workspace.rs b/src/workspace.rs index 23d9a8e1..30e968fe 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -289,20 +289,17 @@ impl Tab { .pane_ids() .iter() .filter_map(|id| { - let pane = self.panes.get(id); - let agent = pane.and_then(|p| p.detected_agent)?; - let state = pane.map(|p| p.state).unwrap_or(AgentState::Unknown); - let seen = pane.map(|p| p.seen).unwrap_or(true); - let agent_label = agent_name(agent).to_string(); + let pane = self.panes.get(id)?; + let agent_label = pane.effective_agent_label()?.to_string(); Some(PaneDetail { pane_id: *id, tab_idx: self.number.saturating_sub(1), tab_label: self.display_name(), label: agent_label.clone(), agent_label, - agent: Some(agent), - state, - seen, + agent: pane.effective_known_agent(), + state: pane.state, + seen: pane.seen, }) }) .collect() @@ -740,22 +737,6 @@ fn pane_attention_priority(state: AgentState, seen: bool) -> u8 { } } -fn agent_name(agent: Agent) -> &'static str { - match agent { - 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", - } -} - pub fn derive_label_from_cwd(cwd: &Path) -> String { if let Some(repo_root) = git_repo_root(cwd) { if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) { @@ -1091,4 +1072,25 @@ mod tests { assert!(details.iter().any(|detail| detail.label == "main·pi")); assert!(details.iter().any(|detail| detail.label == "logs·claude")); } + + #[test] + fn pane_details_include_hook_reported_unknown_agents() { + let mut ws = Workspace::test_new("test"); + let root_pane = ws.tabs[0].root_pane; + ws.tabs[0] + .panes + .get_mut(&root_pane) + .unwrap() + .set_hook_authority( + "custom:hermes".into(), + "hermes".into(), + AgentState::Working, + None, + ); + + let details = ws.pane_details(); + assert_eq!(details.len(), 1); + assert_eq!(details[0].agent_label, "hermes"); + assert_eq!(details[0].agent, None); + } } |
