diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-17 20:06:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-17 20:07:18 +0300 |
| commit | f161be04613211d8fee7ef18e6e506df09da3ba1 (patch) | |
| tree | ec65649c42f95104ec04c33d19c056f169a41189 /src | |
| parent | 45a4318bbbcb2c91d95faee6f31e5846ae3f9ec0 (diff) | |
fix: detect Grok Build agent state
refs #133
Diffstat (limited to 'src')
| -rw-r--r-- | src/config/sound.rs | 3 | ||||
| -rw-r--r-- | src/detect.rs | 82 | ||||
| -rw-r--r-- | src/terminal/state.rs | 71 |
3 files changed, 151 insertions, 5 deletions
diff --git a/src/config/sound.rs b/src/config/sound.rs index 6219d698..d4e279c3 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -36,6 +36,7 @@ pub struct AgentSoundOverrides { pub kimi: AgentSoundSetting, pub droid: AgentSoundSetting, pub amp: AgentSoundSetting, + pub grok: AgentSoundSetting, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] @@ -122,6 +123,7 @@ impl AgentSoundOverrides { Some(Agent::Kimi) => self.kimi, Some(Agent::Droid) => self.droid, Some(Agent::Amp) => self.amp, + Some(Agent::Grok) => self.grok, None => AgentSoundSetting::Default, } } @@ -153,6 +155,7 @@ impl Default for AgentSoundOverrides { kimi: AgentSoundSetting::Default, droid: AgentSoundSetting::Off, amp: AgentSoundSetting::Default, + grok: AgentSoundSetting::Default, } } } diff --git a/src/detect.rs b/src/detect.rs index 9c98a1b9..5d890b90 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -30,6 +30,7 @@ pub enum Agent { Kimi, Droid, Amp, + Grok, } pub fn agent_label(agent: Agent) -> &'static str { @@ -45,6 +46,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Kimi => "kimi", Agent::Droid => "droid", Agent::Amp => "amp", + Agent::Grok => "grok", } } @@ -62,6 +64,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> { "kimi" => Some(Agent::Kimi), "droid" => Some(Agent::Droid), "amp" | "amp-local" => Some(Agent::Amp), + "grok" | "grok-build" => Some(Agent::Grok), _ => None, } } @@ -83,6 +86,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> { "kimi" => Some(Agent::Kimi), "droid" => Some(Agent::Droid), "amp" | "amp-local" => Some(Agent::Amp), + "grok" | "grok-build" => Some(Agent::Grok), _ => None, } } @@ -124,6 +128,7 @@ pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState { Agent::Kimi => detect_kimi(screen_content), Agent::Droid => detect_droid(screen_content), Agent::Amp => detect_amp(screen_content), + Agent::Grok => detect_grok(screen_content), } } @@ -421,6 +426,41 @@ fn detect_amp(content: &str) -> AgentState { AgentState::Idle } +/// Grok Build detection. +/// +/// Blocked permission prompts display a whitelist scope selector with choices +/// like "Yes, proceed" and "No, reject". Working turns show a braille spinner +/// status line such as "⠋ Waiting… 1.8s" plus live controls like +/// "Ctrl+c:cancel" and "Ctrl+Enter:interject". +fn detect_grok(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + if lower.contains("use ← → to choose permission whitelist scope") + || lower.contains("yes, proceed") + || lower.contains("no, reject") + || lower.contains("ctrl+o:yolo") + || lower.contains(":scope") + { + return AgentState::Blocked; + } + + if has_braille_spinner(content) + && (lower.contains("waiting") + || lower.contains("run ") + || lower.contains("read ") + || lower.contains("search ") + || lower.contains("list ")) + { + return AgentState::Working; + } + + if lower.contains("ctrl+c:cancel") && lower.contains("ctrl+enter:interject") { + return AgentState::Working; + } + + AgentState::Idle +} + /// Check for braille spinner characters at the start of a line. /// These are the Unicode braille pattern dots used by CLI spinners. fn has_braille_spinner(content: &str) -> bool { @@ -689,6 +729,8 @@ mod tests { assert_eq!(identify_agent("kimi"), Some(Agent::Kimi)); assert_eq!(identify_agent("copilot"), Some(Agent::GithubCopilot)); assert_eq!(identify_agent("ghcs"), Some(Agent::GithubCopilot)); + assert_eq!(identify_agent("grok"), Some(Agent::Grok)); + assert_eq!(identify_agent("grok-build"), Some(Agent::Grok)); } #[test] @@ -701,6 +743,7 @@ mod tests { Some(Agent::GithubCopilot) ); assert_eq!(parse_agent_label("amp-local"), Some(Agent::Amp)); + assert_eq!(parse_agent_label("grok-build"), Some(Agent::Grok)); } #[test] @@ -708,6 +751,7 @@ mod tests { assert_eq!(agent_label(Agent::Pi), "pi"); assert_eq!(agent_label(Agent::GithubCopilot), "copilot"); assert_eq!(agent_label(Agent::OpenCode), "opencode"); + assert_eq!(agent_label(Agent::Grok), "grok"); } #[test] @@ -1323,6 +1367,44 @@ mod tests { assert_eq!(identify_agent("amp-local"), Some(Agent::Amp)); } + // ---- Grok ---- + + #[test] + fn grok_blocked_on_permission_prompt() { + let screen = "Show recent commit history for analysis\n\ + git -C /home/can/Projects/herdr log --oneline --decorate -n 12\n\ + Use ← → to choose permission whitelist scope\n\n\ + 1 (○) Always allow: git -C\n\ + 2 (●) Yes, proceed\n\ + 3 (○) No, reject (type to add feedback)\n\n\ + 1/3:select │ ←/→:scope │ Ctrl+o:yolo │ Ctrl+c:cancel"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); + } + + #[test] + fn grok_blocked_wins_over_spinner() { + let screen = "⠹ Run git 30s\nYes, proceed\nNo, reject (type to add feedback)"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); + } + + #[test] + fn grok_working_on_waiting_spinner() { + let screen = "⠋ Waiting… 1.8s\nCtrl+c:cancel │ Ctrl+Enter:interject"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); + } + + #[test] + fn grok_working_on_tool_spinner() { + let screen = "⠼ Run git -C /home/can/Projects/herdr log --oneline 1.0s"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); + } + + #[test] + fn grok_idle_after_turn_completed() { + let screen = "yo\n\nTurn completed in 1.7s.\n\n╭────╮\n│ ❯ │\n╰─ gpt-5.4 ─╯"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle); + } + // ---- Helpers ---- #[test] diff --git a/src/terminal/state.rs b/src/terminal/state.rs index f9d5b522..8c27f1a5 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -78,11 +78,13 @@ impl TerminalState { let previous_detected_agent = self.detected_agent; self.detected_agent = agent; self.fallback_state = fallback_state; - if previous_detected_agent.is_some() - && agent != previous_detected_agent - && self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::parse_agent_label(&authority.agent_label) == previous_detected_agent - }) + if self.hook_authority_conflicts_with_detected_agent(agent) + || (previous_detected_agent.is_some() + && agent != previous_detected_agent + && self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label) + == previous_detected_agent + })) { self.hook_authority = None; } @@ -117,6 +119,9 @@ impl TerminalState { 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; + if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { + return None; + } self.hook_authority = Some(HookAuthority { source, agent_label, @@ -127,6 +132,24 @@ impl TerminalState { self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } + fn hook_authority_conflicts_with_detected_agent(&self, detected_agent: Option<Agent>) -> bool { + let Some(detected_agent) = detected_agent else { + return false; + }; + self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label) + .is_some_and(|hook_agent| hook_agent != detected_agent) + }) + } + + fn known_agent_label_conflicts_with_detected_agent(&self, agent_label: &str) -> bool { + let Some(detected_agent) = self.detected_agent else { + return false; + }; + crate::detect::parse_agent_label(agent_label) + .is_some_and(|hook_agent| hook_agent != detected_agent) + } + fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool { let Some(seq) = seq else { return !self.hook_report_sequences.contains_key(source); @@ -418,6 +441,44 @@ mod tests { } #[test] + fn known_hook_authority_does_not_override_different_detected_agent() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Grok), AgentState::Working); + let change = terminal.set_hook_authority( + "herdr:claude".into(), + "claude".into(), + AgentState::Blocked, + None, + None, + ); + + assert!(change.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, Some(Agent::Grok)); + assert_eq!(terminal.effective_agent_label(), Some("grok")); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn detected_agent_clears_conflicting_known_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:claude".into(), + "claude".into(), + AgentState::Blocked, + None, + None, + ); + + terminal.set_detected_state(Some(Agent::Grok), AgentState::Working); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, Some(Agent::Grok)); + assert_eq!(terminal.effective_agent_label(), Some("grok")); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] fn border_label_prefers_manual_label_over_agent_label() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); |
