aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 20:06:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 20:07:18 +0300
commitf161be04613211d8fee7ef18e6e506df09da3ba1 (patch)
treeec65649c42f95104ec04c33d19c056f169a41189 /src
parent45a4318bbbcb2c91d95faee6f31e5846ae3f9ec0 (diff)
fix: detect Grok Build agent state
refs #133
Diffstat (limited to 'src')
-rw-r--r--src/config/sound.rs3
-rw-r--r--src/detect.rs82
-rw-r--r--src/terminal/state.rs71
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);