use regex::Regex;
use crate::agents::{AgentMode, AgentStatus, AgentType, ApprovalType};
use super::{DetectionConfidence, DetectionContext, DetectionResult, StatusDetector};
const TITLE_READY_ICON: char = '◇';
const TITLE_WORKING_ICON: char = '✦';
const TITLE_SILENT_WORKING_ICON: char = '⏲';
const TITLE_ACTION_REQUIRED_ICON: char = '✋';
const BRAILLE_SPINNERS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const WAITING_SPINNER: char = '⠏';
pub struct GeminiDetector {
radio_selected_pattern: Regex,
radio_unselected_pattern: Regex,
}
impl GeminiDetector {
pub fn new() -> Self {
Self {
radio_selected_pattern: Regex::new(r"^●\s*(\d+)\.\s+(.+)$").unwrap(),
radio_unselected_pattern: Regex::new(r"^\s*(\d+)\.\s+(.+)$").unwrap(),
}
}
fn detect_content_approval(&self, content: &str) -> Option<(ApprovalType, String, &str)> {
let lines: Vec<&str> = content.lines().collect();
let check_start = lines.len().saturating_sub(30);
let recent_lines = &lines[check_start..];
if let Some(result) = self.detect_waiting_for_confirmation(recent_lines) {
return Some(result);
}
if let Some(result) = self.detect_radio_button_select(recent_lines) {
return Some(result);
}
if let Some(result) = self.detect_header_approval(recent_lines) {
return Some(result);
}
if let Some(result) = self.detect_confirmation_question(recent_lines) {
return Some(result);
}
None
}
fn detect_radio_button_select(
&self,
lines: &[&str],
) -> Option<(ApprovalType, String, &'static str)> {
let mut choices: Vec<String> = Vec::new();
let mut cursor_position: usize = 0;
let mut found_any = false;
for line in lines {
let trimmed = line.trim();
if let Some(caps) = self.radio_selected_pattern.captures(trimmed) {
if let (Some(num_match), Some(text_match)) = (caps.get(1), caps.get(2)) {
let num: usize = num_match.as_str().parse().unwrap_or(0);
choices.push(text_match.as_str().to_string());
cursor_position = num;
found_any = true;
}
}
else if let Some(caps) = self.radio_unselected_pattern.captures(trimmed) {
if let Some(text_match) = caps.get(2) {
let text = text_match.as_str();
if Self::looks_like_choice(text) || found_any {
choices.push(text.to_string());
found_any = true;
}
}
}
}
if found_any && choices.len() >= 2 {
let details = choices.join(" / ");
return Some((
ApprovalType::UserQuestion {
choices,
multi_select: false,
cursor_position,
},
details,
"radio_button_select",
));
}
None
}
fn looks_like_choice(text: &str) -> bool {
let lower = text.to_lowercase();
lower.contains("allow")
|| lower.contains("deny")
|| lower.contains("yes")
|| lower.contains("no")
|| lower.contains("suggest")
|| lower.contains("cancel")
|| lower.contains("proceed")
|| lower.contains("approve")
|| lower.contains("reject")
|| lower.contains("accept")
|| lower.contains("session")
|| lower.contains("once")
|| lower.contains("always")
}
fn detect_header_approval(
&self,
lines: &[&str],
) -> Option<(ApprovalType, String, &'static str)> {
for line in lines {
let trimmed = line.trim();
if trimmed.contains("Answer Questions") || trimmed.contains("answer questions") {
return Some((
ApprovalType::UserQuestion {
choices: Vec::new(),
multi_select: false,
cursor_position: 0,
},
"Answer Questions".to_string(),
"answer_questions_header",
));
}
if trimmed.contains("Action Required") || trimmed.contains("action required") {
let context_text = lines.iter().map(|l| l.trim()).collect::<Vec<_>>().join(" ");
let approval_type = Self::determine_tool_approval_type(&context_text);
return Some((
approval_type,
"Action Required".to_string(),
"action_required_header",
));
}
}
None
}
fn detect_confirmation_question(
&self,
lines: &[&str],
) -> Option<(ApprovalType, String, &'static str)> {
for line in lines {
let trimmed = line.trim();
if trimmed.contains("Apply this change?") {
return Some((
ApprovalType::FileEdit,
"Apply this change?".to_string(),
"confirmation_question",
));
}
if trimmed.contains("Allow execution of") {
return Some((
ApprovalType::ShellCommand,
trimmed.to_string(),
"confirmation_question",
));
}
if trimmed.contains("Do you want to proceed?") {
let context_text = lines.iter().map(|l| l.trim()).collect::<Vec<_>>().join(" ");
let approval_type = Self::determine_tool_approval_type(&context_text);
return Some((
approval_type,
"Do you want to proceed?".to_string(),
"confirmation_question",
));
}
if trimmed.contains("Ready to start implementation?") {
return Some((
ApprovalType::Other("Plan execution".to_string()),
"Ready to start implementation?".to_string(),
"confirmation_question",
));
}
}
None
}
fn detect_waiting_for_confirmation(
&self,
lines: &[&str],
) -> Option<(ApprovalType, String, &'static str)> {
for line in lines.iter().rev().take(10) {
let trimmed = line.trim();
if trimmed.contains(WAITING_SPINNER)
&& trimmed.contains("Waiting for user confirmation")
{
return Some((
ApprovalType::Other("Gemini approval".to_string()),
"Waiting for user confirmation".to_string(),
"waiting_for_confirmation",
));
}
}
None
}
fn determine_tool_approval_type(context: &str) -> ApprovalType {
let lower = context.to_lowercase();
if lower.contains("write_file")
|| lower.contains("edit_file")
|| lower.contains("patch_file")
{
return ApprovalType::FileEdit;
}
if lower.contains("exec") || lower.contains("shell") || lower.contains("run_command") {
return ApprovalType::ShellCommand;
}
if lower.contains("mcp") {
return ApprovalType::McpTool;
}
ApprovalType::Other("Gemini approval".to_string())
}
fn detect_error(&self, content: &str) -> Option<String> {
super::common::detect_error_common(content, 500)
}
fn detect_content_spinner(&self, content: &str) -> bool {
let lines: Vec<&str> = content.lines().collect();
for line in lines.iter().rev().take(5) {
let trimmed = line.trim();
if let Some(first_char) = trimmed.chars().next() {
if BRAILLE_SPINNERS.contains(&first_char)
&& first_char != WAITING_SPINNER
&& !trimmed.contains("Waiting for user confirmation")
{
return true;
}
}
}
false
}
fn detect_input_prompt(content: &str) -> bool {
let lines: Vec<&str> = content.lines().collect();
if let Some(last) = lines.last() {
let trimmed = last.trim();
if trimmed == ">"
|| trimmed == "!"
|| trimmed == "*"
|| trimmed.ends_with("> ")
|| trimmed.ends_with("! ")
|| trimmed.ends_with("* ")
|| trimmed.starts_with("(r:")
{
return true;
}
}
false
}
pub fn detect_mode(content: &str) -> AgentMode {
let lines: Vec<&str> = content.lines().collect();
let footer_start = lines.len().saturating_sub(5);
let footer_lines = &lines[footer_start..];
for line in footer_lines {
let trimmed = line.trim();
if trimmed.contains("YOLO mode") {
return AgentMode::AutoApprove;
}
if trimmed.contains("Plan mode") {
return AgentMode::Plan;
}
}
if let Some(last) = lines.last() {
let trimmed = last.trim();
if trimmed == "*" || trimmed.ends_with("* ") {
return AgentMode::AutoApprove;
}
}
AgentMode::Default
}
}
impl Default for GeminiDetector {
fn default() -> Self {
Self::new()
}
}
impl StatusDetector for GeminiDetector {
fn detect_status(&self, title: &str, content: &str) -> AgentStatus {
self.detect_status_with_reason(title, content, &DetectionContext::default())
.status
}
fn detect_status_with_reason(
&self,
title: &str,
content: &str,
_context: &DetectionContext,
) -> DetectionResult {
if title.contains(TITLE_ACTION_REQUIRED_ICON) {
if let Some((approval_type, details, rule)) = self.detect_content_approval(content) {
return DetectionResult::new(
AgentStatus::AwaitingApproval {
approval_type,
details: details.clone(),
},
rule,
DetectionConfidence::High,
)
.with_matched_text(&details);
}
return DetectionResult::new(
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::Other("Gemini approval".to_string()),
details: String::new(),
},
"title_action_required_icon",
DetectionConfidence::High,
)
.with_matched_text(title);
}
if let Some((approval_type, details, rule)) = self.detect_content_approval(content) {
return DetectionResult::new(
AgentStatus::AwaitingApproval {
approval_type,
details: details.clone(),
},
rule,
DetectionConfidence::High,
)
.with_matched_text(&details);
}
if let Some(message) = self.detect_error(content) {
return DetectionResult::new(
AgentStatus::Error {
message: message.clone(),
},
"error_pattern",
DetectionConfidence::High,
)
.with_matched_text(&message);
}
if title.contains(TITLE_WORKING_ICON) {
return DetectionResult::new(
AgentStatus::Processing {
activity: String::new(),
},
"title_working_icon",
DetectionConfidence::High,
)
.with_matched_text(title);
}
if title.contains(TITLE_SILENT_WORKING_ICON) {
return DetectionResult::new(
AgentStatus::Processing {
activity: String::new(),
},
"title_silent_working_icon",
DetectionConfidence::High,
)
.with_matched_text(title);
}
if title.contains(TITLE_READY_ICON) {
return DetectionResult::new(
AgentStatus::Idle,
"title_ready_icon",
DetectionConfidence::High,
)
.with_matched_text(title);
}
if self.detect_content_spinner(content) {
return DetectionResult::new(
AgentStatus::Processing {
activity: String::new(),
},
"braille_spinner",
DetectionConfidence::Medium,
);
}
if Self::detect_input_prompt(content) {
return DetectionResult::new(
AgentStatus::Idle,
"input_prompt",
DetectionConfidence::Medium,
);
}
DetectionResult::new(
AgentStatus::Processing {
activity: String::new(),
},
"fallback_processing",
DetectionConfidence::Low,
)
}
fn agent_type(&self) -> AgentType {
AgentType::GeminiCli
}
fn approval_keys(&self) -> &str {
"Enter"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn detect(title: &str, content: &str) -> DetectionResult {
let detector = GeminiDetector::new();
detector.detect_status_with_reason(title, content, &DetectionContext::default())
}
#[test]
fn test_title_ready_icon_idle() {
let result = detect("◇ Gemini", "Some content\n> ");
assert!(matches!(result.status, AgentStatus::Idle));
assert_eq!(result.reason.rule, "title_ready_icon");
assert_eq!(result.reason.confidence, DetectionConfidence::High);
}
#[test]
fn test_title_working_icon_processing() {
let result = detect("✦ Gemini", "Working on something...");
assert!(matches!(result.status, AgentStatus::Processing { .. }));
assert_eq!(result.reason.rule, "title_working_icon");
assert_eq!(result.reason.confidence, DetectionConfidence::High);
}
#[test]
fn test_title_silent_working_icon_processing() {
let result = detect("⏲ Gemini", "Waiting for response...");
assert!(matches!(result.status, AgentStatus::Processing { .. }));
assert_eq!(result.reason.rule, "title_silent_working_icon");
assert_eq!(result.reason.confidence, DetectionConfidence::High);
}
#[test]
fn test_title_action_required_icon_generic() {
let result = detect("✋ Gemini", "Some content without specific pattern");
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval { .. }
));
assert_eq!(result.reason.rule, "title_action_required_icon");
assert_eq!(result.reason.confidence, DetectionConfidence::High);
}
#[test]
fn test_title_action_required_with_radio_buttons() {
let content = "Some tool output\n● 1. Allow once\n 2. Allow for this session\n 3. No, suggest changes\n";
let result = detect("✋ Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::UserQuestion { .. },
..
}
));
assert_eq!(result.reason.rule, "radio_button_select");
assert_eq!(result.reason.confidence, DetectionConfidence::High);
}
#[test]
fn test_radio_button_select() {
let content =
"Tool wants to do something\n● 1. Allow once\n 2. Allow for this session\n 3. No, suggest changes\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::UserQuestion { .. },
..
}
));
assert_eq!(result.reason.rule, "radio_button_select");
if let AgentStatus::AwaitingApproval {
approval_type:
ApprovalType::UserQuestion {
choices,
cursor_position,
multi_select,
},
..
} = &result.status
{
assert_eq!(choices.len(), 3);
assert_eq!(choices[0], "Allow once");
assert_eq!(choices[1], "Allow for this session");
assert_eq!(choices[2], "No, suggest changes");
assert_eq!(*cursor_position, 1);
assert!(!multi_select);
}
}
#[test]
fn test_action_required_header() {
let content = "Some output\nAction Required\nwrite_file: src/main.rs\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::FileEdit,
..
}
));
assert_eq!(result.reason.rule, "action_required_header");
}
#[test]
fn test_answer_questions_header() {
let content = "Some output\nAnswer Questions\nWhat should we do?\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::UserQuestion { .. },
..
}
));
assert_eq!(result.reason.rule, "answer_questions_header");
}
#[test]
fn test_confirmation_question_apply_change() {
let content = "Diff output here\nApply this change?\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::FileEdit,
..
}
));
assert_eq!(result.reason.rule, "confirmation_question");
}
#[test]
fn test_confirmation_question_allow_execution() {
let content = "Command preview\nAllow execution of `ls -la`\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::ShellCommand,
..
}
));
assert_eq!(result.reason.rule, "confirmation_question");
}
#[test]
fn test_confirmation_question_ready_to_implement() {
let content = "Plan summary here\nReady to start implementation?\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::Other(_),
..
}
));
if let AgentStatus::AwaitingApproval {
approval_type: ApprovalType::Other(ref s),
..
} = result.status
{
assert_eq!(s, "Plan execution");
}
}
#[test]
fn test_waiting_for_confirmation() {
let content = "Some output\n⠏ Waiting for user confirmation...\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval { .. }
));
assert_eq!(result.reason.rule, "waiting_for_confirmation");
assert_eq!(result.reason.confidence, DetectionConfidence::High);
}
#[test]
fn test_input_prompt_normal_mode() {
let content = "Previous output\n> ";
let result = detect("Gemini", content);
assert!(matches!(result.status, AgentStatus::Idle));
assert_eq!(result.reason.rule, "input_prompt");
assert_eq!(result.reason.confidence, DetectionConfidence::Medium);
}
#[test]
fn test_input_prompt_shell_mode() {
let content = "Previous output\n! ";
let result = detect("Gemini", content);
assert!(matches!(result.status, AgentStatus::Idle));
assert_eq!(result.reason.rule, "input_prompt");
}
#[test]
fn test_input_prompt_yolo_mode() {
let content = "Previous output\n* ";
let result = detect("Gemini", content);
assert!(matches!(result.status, AgentStatus::Idle));
assert_eq!(result.reason.rule, "input_prompt");
}
#[test]
fn test_input_prompt_reverse_search() {
let content = "Previous output\n(r:search term) ";
let result = detect("Gemini", content);
assert!(matches!(result.status, AgentStatus::Idle));
assert_eq!(result.reason.rule, "input_prompt");
}
#[test]
fn test_braille_spinner_processing() {
let content = "Working on task\n⠋ Thinking...";
let result = detect("Gemini", content);
assert!(matches!(result.status, AgentStatus::Processing { .. }));
assert_eq!(result.reason.rule, "braille_spinner");
assert_eq!(result.reason.confidence, DetectionConfidence::Medium);
}
#[test]
fn test_braille_spinner_not_confused_with_waiting() {
let content = "Some output\n⠏ Waiting for user confirmation...\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval { .. }
));
assert_eq!(result.reason.rule, "waiting_for_confirmation");
}
#[test]
fn test_error_detection() {
let content = "Processing...\nError: something went wrong\n";
let result = detect("Gemini", content);
assert!(matches!(result.status, AgentStatus::Error { .. }));
assert_eq!(result.reason.rule, "error_pattern");
assert_eq!(result.reason.confidence, DetectionConfidence::High);
}
#[test]
fn test_fallback_processing() {
let content = "Some random output without any indicators";
let result = detect("Gemini", content);
assert!(matches!(result.status, AgentStatus::Processing { .. }));
assert_eq!(result.reason.rule, "fallback_processing");
assert_eq!(result.reason.confidence, DetectionConfidence::Low);
}
#[test]
fn test_mode_yolo_prompt() {
let content = "Some output\n* ";
let mode = GeminiDetector::detect_mode(content);
assert_eq!(mode, AgentMode::AutoApprove);
}
#[test]
fn test_mode_yolo_footer() {
let content = "Some output\nStatus bar | YOLO mode\n> ";
let mode = GeminiDetector::detect_mode(content);
assert_eq!(mode, AgentMode::AutoApprove);
}
#[test]
fn test_mode_plan() {
let content = "Some output\nStatus bar | Plan mode\n> ";
let mode = GeminiDetector::detect_mode(content);
assert_eq!(mode, AgentMode::Plan);
}
#[test]
fn test_mode_default() {
let content = "Some output\n> ";
let mode = GeminiDetector::detect_mode(content);
assert_eq!(mode, AgentMode::Default);
}
#[test]
fn test_title_action_required_overrides_content_spinner() {
let content = "⠋ Processing something\n";
let result = detect("✋ Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval { .. }
));
}
#[test]
fn test_content_approval_overrides_title_working() {
let content =
"Tool output\n● 1. Allow once\n 2. Allow for this session\n 3. No, suggest changes\n";
let result = detect("✦ Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval { .. }
));
assert_eq!(result.reason.rule, "radio_button_select");
}
#[test]
fn test_error_overrides_title_ready() {
let content = "Error: connection refused\n";
let result = detect("◇ Gemini", content);
assert!(matches!(result.status, AgentStatus::Error { .. }));
}
#[test]
fn test_title_working_overrides_input_prompt() {
let content = "Previous output\n> ";
let result = detect("✦ Gemini", content);
assert!(matches!(result.status, AgentStatus::Processing { .. }));
assert_eq!(result.reason.rule, "title_working_icon");
}
#[test]
fn test_action_required_shell_command() {
let content = "Action Required\nexec: ls -la /tmp\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::ShellCommand,
..
}
));
}
#[test]
fn test_action_required_mcp_tool() {
let content = "Action Required\nmcp_tool: read_file\n";
let result = detect("Gemini", content);
assert!(matches!(
result.status,
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::McpTool,
..
}
));
}
#[test]
fn test_agent_type() {
let detector = GeminiDetector::new();
assert_eq!(detector.agent_type(), AgentType::GeminiCli);
}
#[test]
fn test_approval_keys() {
let detector = GeminiDetector::new();
assert_eq!(detector.approval_keys(), "Enter");
}
#[test]
fn test_detect_status_delegates_to_with_reason() {
let detector = GeminiDetector::new();
let status = detector.detect_status("◇ Gemini", "Some content\n> ");
assert!(matches!(status, AgentStatus::Idle));
}
}