diff options
Diffstat (limited to 'src/detect/agents')
| -rw-r--r-- | src/detect/agents/amp.rs | 46 | ||||
| -rw-r--r-- | src/detect/agents/antigravity.rs | 76 | ||||
| -rw-r--r-- | src/detect/agents/claude_code.rs | 434 | ||||
| -rw-r--r-- | src/detect/agents/cline.rs | 19 | ||||
| -rw-r--r-- | src/detect/agents/codex.rs | 258 | ||||
| -rw-r--r-- | src/detect/agents/cursor.rs | 79 | ||||
| -rw-r--r-- | src/detect/agents/droid.rs | 39 | ||||
| -rw-r--r-- | src/detect/agents/gemini.rs | 31 | ||||
| -rw-r--r-- | src/detect/agents/github_copilot.rs | 28 | ||||
| -rw-r--r-- | src/detect/agents/grok.rs | 37 | ||||
| -rw-r--r-- | src/detect/agents/hermes.rs | 30 | ||||
| -rw-r--r-- | src/detect/agents/kilo.rs | 13 | ||||
| -rw-r--r-- | src/detect/agents/kimi.rs | 97 | ||||
| -rw-r--r-- | src/detect/agents/kiro.rs | 60 | ||||
| -rw-r--r-- | src/detect/agents/mod.rs | 105 | ||||
| -rw-r--r-- | src/detect/agents/opencode.rs | 58 | ||||
| -rw-r--r-- | src/detect/agents/pi.rs | 9 | ||||
| -rw-r--r-- | src/detect/agents/qodercli.rs | 86 |
18 files changed, 0 insertions, 1505 deletions
diff --git a/src/detect/agents/amp.rs b/src/detect/agents/amp.rs deleted file mode 100644 index b1fcba4d..00000000 --- a/src/detect/agents/amp.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::super::AgentState; - -/// Amp (Sourcegraph) detection. -/// -/// Blocked approval prompts use a shared footer with options like -/// "Approve", "Allow All for This Session", "Allow All for Every Session", -/// "Allow File for Every Session", and "Deny with feedback". The header varies -/// by approval type, for example "Invoke tool ...?", "Run this command?", -/// "Allow editing file:", or "Allow creating file:". -/// -/// Working layout: -/// ```text -/// ✓ Search Map the core runtime architecture... -/// ⋯ Oracle ▼ -/// ≈ Running tools... Esc to cancel -/// ``` -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - if lower.contains("esc to cancel") { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_waiting_for_approval = lower.contains("waiting for approval"); - let has_approval_header = lower.contains("invoke tool") - || lower.contains("run this command?") - || lower.contains("allow editing file:") - || lower.contains("allow creating file:") - || lower.contains("confirm tool call"); - let has_approval_actions = lower.contains("approve") - && (lower.contains("allow all for this session") - || lower.contains("allow all for every session") - || lower.contains("allow file for every session") - || lower.contains("deny with feedback")); - - has_approval_actions && (has_waiting_for_approval || has_approval_header) -} diff --git a/src/detect/agents/antigravity.rs b/src/detect/agents/antigravity.rs deleted file mode 100644 index 9790c5a4..00000000 --- a/src/detect/agents/antigravity.rs +++ /dev/null @@ -1,76 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - if has_antigravity_spinner(content) || has_antigravity_background_tasks(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_permission_request = lower.contains("requesting permission for:"); - let has_permission_question = lower.contains("do you want to proceed?"); - let has_permission_controls = lower.contains("tab amend") && lower.contains("edit command"); - has_permission_request && (has_permission_question || has_permission_controls) -} - -fn has_antigravity_spinner(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim_start(); - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !('\u{2800}'..='\u{28FF}').contains(&first) { - return false; - } - - let rest = chars - .as_str() - .trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) - .trim_start(); - status_word_is_active(rest) - }) -} - -fn has_antigravity_background_tasks(content: &str) -> bool { - let bottom_lines: Vec<&str> = content - .lines() - .rev() - .filter(|line| !line.trim().is_empty()) - .take(5) - .collect(); - - bottom_lines.into_iter().any(|line| { - let line = line.trim().to_lowercase(); - line.contains("/tasks") && antigravity_task_count(&line).is_some_and(|count| count > 0) - }) -} - -fn antigravity_task_count(line: &str) -> Option<u32> { - for marker in [" task(s)", " tasks", " task"] { - let Some((before, _)) = line.split_once(marker) else { - continue; - }; - let raw_count = before.split_whitespace().last()?.trim_matches(|c| c == '·'); - if let Ok(count) = raw_count.parse() { - return Some(count); - } - } - None -} - -fn status_word_is_active(rest: &str) -> bool { - let Some(word) = rest.split_whitespace().next() else { - return false; - }; - word.trim_end_matches(|c: char| !c.is_alphabetic()) - .to_ascii_lowercase() - .ends_with("ing") -} diff --git a/src/detect/agents/claude_code.rs b/src/detect/agents/claude_code.rs deleted file mode 100644 index b159fede..00000000 --- a/src/detect/agents/claude_code.rs +++ /dev/null @@ -1,434 +0,0 @@ -use super::super::{has_confirmation_prompt, has_selection_prompt, AgentState}; - -/// Claude Code detection. The most complex — it has a structured prompt box UI. -/// -/// Screen layout: -/// ```text -/// (agent output / tool results) -/// ───────────────────────── (top border) -/// ❯ _ (prompt line) -/// ───────────────────────── (bottom border) -/// ``` -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_live_blocked_form(content) { - return AgentState::Blocked; - } - - if has_dynamic_workflow_prompt(&lower) { - return AgentState::Blocked; - } - - if has_working_chrome(content) { - return AgentState::Working; - } - - if !has_prompt_box(content) && has_claude_blocked_prompt(content, &lower) { - return AgentState::Blocked; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - if has_live_input_prompt_box(content) { - return false; - } - - let lower = content.to_lowercase(); - has_live_blocked_form(content) - || has_dynamic_workflow_prompt(&lower) - || lower.contains("do you want to proceed?") - && has_claude_yes_no_choice(content) - && (lower.contains("bash command") - || lower.contains("bash(") - || lower.contains("contains expansion") - || lower.contains("tab to amend") - || lower.contains("ctrl+e to explain")) -} - -pub(in crate::detect) fn has_idle_recap_notice(content: &str) -> bool { - if !has_prompt_box(content) || has_visible_blocker(content) { - return false; - } - - let above_prompt = content_above_prompt_box(content); - let bottom_lines = bottom_non_empty_lines(above_prompt, 8); - let Some(last_line) = bottom_lines.last() else { - return false; - }; - if !last_line - .to_ascii_lowercase() - .contains("(disable recaps in /config)") - { - return false; - } - let bottom = normalize_lines(&bottom_lines).to_ascii_lowercase(); - - bottom.contains("※ recap:") - && !bottom.contains("esc to interrupt") - && !bottom.contains("ctrl+c to interrupt") -} - -pub(super) fn has_working_chrome(content: &str) -> bool { - let above = content_above_prompt_box(content); - let above_lower = above.to_lowercase(); - above_lower.contains("esc to interrupt") - || above_lower.contains("ctrl+c to interrupt") - || has_running_status_line(above) - || has_spinner_activity(above) -} - -pub(super) fn is_transcript_viewer(content: &str) -> bool { - let bottom_lines = bottom_non_empty_lines(content, 3); - let Some(last_line) = bottom_lines.last() else { - return false; - }; - let bottom_text = normalize_lines(&bottom_lines); - - bottom_text.contains("showing detailed transcript") - && bottom_text.contains("ctrl+o to toggle") - && (bottom_text.contains("ctrl+e to show all") - || bottom_text.contains("ctrl+e to collapse")) - && transcript_control_tail(last_line) -} - -pub(super) fn has_prompt_box(content: &str) -> bool { - prompt_box_body_lines(content) - .is_some_and(|lines| lines.iter().any(|line| line.trim_start().starts_with('❯'))) -} - -fn has_live_input_prompt_box(content: &str) -> bool { - prompt_box_body_lines(content).is_some_and(|lines| { - let non_empty: Vec<&str> = lines - .iter() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect(); - let Some(first) = non_empty.first() else { - return false; - }; - first.trim_start().starts_with('❯') - && !non_empty - .iter() - .any(|line| claude_prompt_box_line_is_selector_chrome(line)) - }) -} - -fn claude_prompt_box_line_is_selector_chrome(line: &str) -> bool { - let trimmed = line.trim().trim_start_matches('❯').trim_start(); - let lower = trimmed.to_ascii_lowercase(); - - lower.contains("enter to select") - || lower.contains("enter to confirm") - || lower.contains("enter to submit") - || lower.contains("esc to cancel") - || lower.contains("tab/arrow") - || lower.contains("arrow keys") - || lower.contains("↑/↓") - || lower.contains("↑↓") - || lower.contains("ctrl+g to edit") - || lower.contains("ctrl+e to explain") - || trimmed - .split_once('.') - .is_some_and(|(prefix, _)| prefix.trim().parse::<u32>().is_ok()) -} - -fn prompt_box_body_lines(content: &str) -> Option<Vec<&str>> { - let lines: Vec<&str> = content.lines().collect(); - let top_border_index = claude_prompt_box_top_border_index(&lines)?; - - Some( - lines[top_border_index + 1..] - .iter() - .take_while(|line| !is_horizontal_rule(line)) - .copied() - .collect(), - ) -} - -/// Claude uses the same generic Select and Dialog widgets for both -/// permission flows and ordinary slash/settings menus. Match only the -/// permission and interview prompts that actually need user input. -fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool { - has_confirmation_prompt(lower_content) - || lower_content.contains("do you want to proceed?") - || lower_content.contains("would you like to proceed?") - || has_dynamic_workflow_prompt(lower_content) - || lower_content.contains("waiting for permission") - || lower_content.contains("do you want to allow this connection?") - || lower_content.contains("tab to amend") - || lower_content.contains("ctrl+e to explain") - || lower_content.contains("review your answers") - || lower_content.contains("skip interview and plan immediately") - || (has_selection_prompt(content) && has_claude_yes_no_choice(content)) -} - -fn has_dynamic_workflow_prompt(lower_content: &str) -> bool { - lower_content.contains("run a dynamic workflow?") && lower_content.contains("esc to cancel") -} - -fn has_live_blocked_form(content: &str) -> bool { - let region = content_after_last_horizontal_rule(content); - region.lines().any(|line| { - let lower = line.to_lowercase(); - lower.contains("enter to select") - && lower.contains("esc to cancel") - && (lower.contains("tab/arrow keys to navigate") - || lower.contains("arrow keys to navigate") - || lower.contains("arrows to navigate") - || lower.contains("↑/↓ to navigate") - || lower.contains("↑↓ to navigate")) - }) -} - -fn has_running_status_line(content_above_prompt: &str) -> bool { - let Some(line) = content_above_prompt - .lines() - .rev() - .find(|line| !line.trim().is_empty()) - else { - return false; - }; - - is_background_agent_wait_line(line) || is_still_running_status_line(line) -} - -fn is_background_agent_wait_line(line: &str) -> bool { - let mut text = line.trim(); - if !text.starts_with("Waiting for ") && !text.starts_with("waiting for ") { - let mut chars = text.chars(); - let Some(first) = chars.next() else { - return false; - }; - if first.is_alphanumeric() { - return false; - } - text = chars.as_str().trim_start(); - } - - let lower = text.to_ascii_lowercase(); - let Some(rest) = lower.strip_prefix("waiting for ") else { - return false; - }; - let Some((count, rest)) = rest.split_once(' ') else { - return false; - }; - if count.parse::<u32>().ok().is_none_or(|count| count == 0) { - return false; - } - - rest == "background agent to finish" || rest == "background agents to finish" -} - -fn is_still_running_status_line(line: &str) -> bool { - let lower = line.to_ascii_lowercase(); - let words: Vec<&str> = lower.split_whitespace().collect(); - - for (index, word) in words.iter().enumerate() { - let Ok(count) = word.parse::<u32>() else { - continue; - }; - if count == 0 { - continue; - } - - if matches!( - words.get(index + 1..index + 4), - Some(["shell" | "shells", "still", "running"]) - ) { - return true; - } - - if matches!( - words.get(index + 1..index + 5), - Some(["local", "agent" | "agents", "still", "running"]) - ) { - return true; - } - } - - false -} - -fn has_claude_yes_no_choice(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line - .trim() - .trim_start_matches('❯') - .trim_start() - .to_lowercase(); - trimmed == "yes" - || trimmed == "no" - || trimmed.starts_with("1. yes") - || trimmed.starts_with("2. no") - || trimmed.starts_with("yes, and ") - || trimmed.starts_with("no, and tell claude") - }) -} - -/// Claude Code spinner characters + activity label. -/// The verb changes frequently ("Processing…", "Pouncing…", etc.), so rely -/// on the spinner glyph + trailing ellipsis rather than specific wording. -/// Include Claude's narrow-pane middle-dot frame too. -pub(in crate::detect) fn has_spinner_activity(content: &str) -> bool { - const SPINNER_CHARS: &str = "·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆"; - for line in content.lines() { - let trimmed = line.trim(); - let mut chars = trimmed.chars(); - if let Some(first) = chars.next() { - if SPINNER_CHARS.contains(first) { - let rest: String = chars.collect(); - if rest.starts_with(' ') - && rest.contains('\u{2026}') - && rest.chars().any(|c| c.is_alphanumeric()) - { - return true; - } - } - } - } - false -} - -/// Extract content above Claude's prompt box. -/// The prompt box is two ─── border lines with ❯ between them. -pub(in crate::detect) fn content_above_prompt_box(content: &str) -> &str { - let lines: Vec<&str> = content.lines().collect(); - - if let Some(i) = claude_prompt_box_top_border_index(&lines) { - let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum(); - return &content[..byte_offset.min(content.len())]; - } - - // No prompt box found, return all content - content -} - -fn content_after_last_horizontal_rule(content: &str) -> &str { - let mut last_rule_end = 0usize; - let mut offset = 0usize; - for line in content.lines() { - let next_offset = offset + line.len() + 1; - if is_horizontal_rule(line) { - last_rule_end = next_offset.min(content.len()); - } - offset = next_offset; - } - - &content[last_rule_end..] -} - -fn claude_prompt_box_top_border_index(lines: &[&str]) -> Option<usize> { - let mut border_count = 0; - - for i in (0..lines.len()).rev() { - if is_horizontal_rule(lines[i]) { - border_count += 1; - if border_count == 2 { - return Some(i); - } - } - } - - None -} - -fn is_horizontal_rule(line: &str) -> bool { - let trimmed = line.trim(); - if trimmed.is_empty() { - return false; - } - - let rule_chars = trimmed.chars().take_while(|&c| c == '─').count(); - if rule_chars == 0 { - return false; - } - - let rule_bytes = trimmed - .char_indices() - .nth(rule_chars) - .map(|(index, _)| index) - .unwrap_or(trimmed.len()); - let suffix = trimmed[rule_bytes..].trim_start(); - - suffix.is_empty() || rule_chars >= 3 -} - -fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> { - let mut lines: Vec<&str> = content - .lines() - .rev() - .filter(|line| !line.trim().is_empty()) - .take(max_lines) - .collect(); - lines.reverse(); - lines -} - -fn normalize_lines(lines: &[&str]) -> String { - lines - .iter() - .flat_map(|line| line.split_whitespace()) - .collect::<Vec<_>>() - .join(" ") - .to_lowercase() -} - -fn transcript_control_tail(line: &str) -> bool { - let lower = line.to_lowercase(); - lower.contains("ctrl+e") - || lower.contains("show all") - || lower.contains("collapse") - || lower.contains("verbose") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn prompt_box_below(content_above_prompt: &str) -> String { - format!( - "{content_above_prompt}\n────────────────────────────────\n❯ \n────────────────────────────────\n" - ) - } - - #[test] - fn shell_still_running_status_line_is_working() { - let content = prompt_box_below( - "● Started. I'll tell you when it finishes.\n\n✻ Crunched for 7s · 1 shell still running", - ); - - assert_eq!(detect(&content), AgentState::Working); - assert!(has_working_chrome(&content)); - } - - #[test] - fn local_agent_still_running_status_line_is_working() { - let content = prompt_box_below( - "● Hey. What do you want to work on?\n\n✻ Worked for 4s · 2 local agents still running", - ); - - assert_eq!(detect(&content), AgentState::Working); - assert!(has_working_chrome(&content)); - } - - #[test] - fn lower_agent_picker_shell_count_is_not_working_chrome() { - let content = prompt_box_below(" ~/P/herdr ⎇ master ▱▱▱▱▱ 0%\n 1 shell · ← for agents"); - - assert_eq!(detect(&content), AgentState::Idle); - assert!(!has_working_chrome(&content)); - } - - #[test] - fn stale_shell_running_line_above_newer_output_is_not_working_chrome() { - let content = prompt_box_below( - "● Started. I'll tell you when it finishes.\n\n✻ Crunched for 7s · 1 shell still running\n\n● hi", - ); - - assert_eq!(detect(&content), AgentState::Idle); - assert!(!has_working_chrome(&content)); - } -} diff --git a/src/detect/agents/cline.rs b/src/detect/agents/cline.rs deleted file mode 100644 index c0ce6f6e..00000000 --- a/src/detect/agents/cline.rs +++ /dev/null @@ -1,19 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - // Blocked - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Cline defaults to working (unlike most agents that default to idle) - AgentState::Working -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - lower.contains("let cline use this tool") - || ((lower.contains("[act mode]") || lower.contains("[plan mode]")) - && (lower.contains("execute command?") || lower.contains("use this tool?")) - && lower.contains("yes")) -} diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs deleted file mode 100644 index 93d2998c..00000000 --- a/src/detect/agents/codex.rs +++ /dev/null @@ -1,258 +0,0 @@ -use super::super::{has_confirmation_prompt, has_interrupt_pattern, AgentState}; - -pub(super) fn detect(content: &str) -> AgentState { - // Strong blocked patterns are structural Codex UI chrome, so they can win - // even when the prompt region is visible. - if has_codex_strong_blocked_prompt(content) { - return AgentState::Blocked; - } - - let lower = content.to_lowercase(); - - // Working - if has_codex_working_status_at_current_prompt(content) { - return AgentState::Working; - } - - // Weak blocked patterns are too broad to become visible blockers, but the - // legacy state detector still reports them as blocked for compatibility. - if has_codex_weak_blocked_prompt(&lower) { - return AgentState::Blocked; - } - - // Fallback working signals for narrow captures where the footer scrolled - // out or the working row is the only Codex chrome visible. - if has_interrupt_pattern(&lower) || has_codex_working_header(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - has_codex_strong_blocked_prompt(content) -} - -pub(super) fn has_visible_working(content: &str) -> bool { - has_codex_live_working_at_current_prompt(content) - || (!has_codex_current_prompt(content) && has_codex_visible_working_without_prompt(content)) -} - -pub(super) fn is_transcript_viewer(content: &str) -> bool { - let bottom_lines = bottom_non_empty_lines(content, 3); - let Some(last_line) = bottom_lines.last() else { - return false; - }; - let bottom_text = normalize_lines(&bottom_lines); - - bottom_text.contains("↑/↓ to scroll") - && bottom_text.contains("pgup/pgdn to page") - && bottom_text.contains("home/end to jump") - && bottom_text.contains("q to quit") - && has_codex_edit_prev_controls(&bottom_text) - && transcript_control_tail(last_line) -} - -fn has_codex_edit_prev_controls(bottom_text: &str) -> bool { - bottom_text.contains("esc to edit prev") || bottom_text.contains("esc/← to edit prev") -} - -fn has_codex_visible_working_without_prompt(content: &str) -> bool { - let mut recent_lines = content.lines().rev().filter(|line| !line.trim().is_empty()); - let Some(last_line) = recent_lines.next() else { - return false; - }; - - if codex_live_working_line(last_line) { - return true; - } - - codex_status_detail_line(last_line) - && recent_lines - .take(4) - .find(|line| codex_block_marker_line(line)) - .is_some_and(codex_live_working_line) -} - -fn has_codex_strong_blocked_prompt(content: &str) -> bool { - let lines: Vec<&str> = content.lines().collect(); - let live_region = lines - .iter() - .rposition(|line| codex_prompt_line(line)) - .map(|prompt_index| lines[prompt_index + 1..].join("\n")) - .unwrap_or_else(|| content.to_string()); - let lower_content = live_region.to_lowercase(); - - lower_content.contains("press enter to confirm or esc to cancel") - || lower_content.contains("enter to submit answer") - || lower_content.contains("enter to submit all") - || lower_content.contains("allow command?") -} - -fn has_codex_weak_blocked_prompt(lower_content: &str) -> bool { - lower_content.contains("[y/n]") - || lower_content.contains("yes (y)") - || has_confirmation_prompt(lower_content) -} - -fn has_codex_live_working_at_current_prompt(content: &str) -> bool { - codex_last_block_marker_before_current_prompt(content).is_some_and(codex_live_working_line) -} - -fn has_codex_working_status_at_current_prompt(content: &str) -> bool { - codex_last_block_marker_before_current_prompt(content).is_some_and(codex_working_status_line) -} - -fn codex_last_block_marker_before_current_prompt(content: &str) -> Option<&str> { - let (lines, prompt_index) = codex_current_prompt_region(content)?; - - lines[..prompt_index] - .iter() - .rev() - .find(|line| codex_block_marker_line(line)) - .copied() -} - -fn has_codex_working_header(content: &str) -> bool { - content.lines().any(codex_working_status_line) -} - -fn codex_live_working_line(line: &str) -> bool { - if codex_queued_input_header_line(line) { - return true; - } - - let trimmed = line.trim_start(); - let lower = trimmed.to_lowercase(); - codex_working_status_line(line) - && (trimmed.contains("Waiting for background terminal") - || has_codex_status_interrupt_hint(&lower) - || lower.contains("background terminal running") - || lower.contains("/ps to view") - || lower.contains("/stop to close")) -} - -fn codex_working_status_line(line: &str) -> bool { - if codex_queued_input_header_line(line) { - return true; - } - - let trimmed = line.trim_start(); - let lower = trimmed.to_lowercase(); - trimmed.starts_with('•') - && (trimmed.contains("Working (") - || trimmed.contains("Waiting for background terminal (") - || has_codex_status_interrupt_hint(&lower) - || lower.contains("reviewing approval request (") - || (lower.contains("reviewing ") && lower.contains(" approval requests (")) - || trimmed.contains("Booting MCP server:")) -} - -fn has_codex_status_interrupt_hint(lower_line: &str) -> bool { - let Some((before_escape, after_escape)) = lower_line.split_once(" • esc") else { - return false; - }; - - has_codex_status_elapsed(before_escape) && has_codex_status_escape_suffix(after_escape) -} - -fn has_codex_status_elapsed(before_escape: &str) -> bool { - let Some((_, elapsed)) = before_escape.rsplit_once('(') else { - return false; - }; - - let parts: Vec<&str> = elapsed.split_whitespace().collect(); - (1..=3).contains(&parts.len()) - && parts.iter().all(|part| { - part.len() >= 2 - && matches!(part.as_bytes().last(), Some(b'h' | b'm' | b's')) - && part[..part.len() - 1].chars().all(|ch| ch.is_ascii_digit()) - }) -} - -fn has_codex_status_escape_suffix(after_escape: &str) -> bool { - let suffix = after_escape.trim_start(); - if suffix.starts_with('…') || suffix.starts_with("to interrupt") { - return true; - } - - let Some(rest) = suffix.strip_prefix("to ") else { - return false; - }; - if rest.starts_with('…') { - return true; - } - - rest.split_once('…') - .map(|(fragment, _)| !fragment.is_empty() && "interrupt".starts_with(fragment.trim_end())) - .unwrap_or(false) -} - -fn has_codex_current_prompt(content: &str) -> bool { - codex_current_prompt_region(content).is_some() -} - -fn codex_current_prompt_region(content: &str) -> Option<(Vec<&str>, usize)> { - let lines: Vec<&str> = content.lines().collect(); - let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?; - - if lines[prompt_index + 1..] - .iter() - .any(|line| codex_block_marker_line(line)) - { - return None; - } - - Some((lines, prompt_index)) -} - -fn codex_prompt_line(line: &str) -> bool { - line == "›" || line.starts_with("› ") -} - -fn codex_block_marker_line(line: &str) -> bool { - line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓') -} - -fn codex_status_detail_line(line: &str) -> bool { - line.trim_start().starts_with('└') -} - -fn codex_queued_input_header_line(line: &str) -> bool { - let trimmed = line.trim_start(); - if !trimmed.starts_with('•') { - return false; - } - - let lower = trimmed.to_lowercase(); - lower.starts_with("• queued follow-up inputs") - || lower.starts_with("• messages to be submitted after next tool call") -} - -fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> { - let mut lines: Vec<&str> = content - .lines() - .rev() - .filter(|line| !line.trim().is_empty()) - .take(max_lines) - .collect(); - lines.reverse(); - lines -} - -fn normalize_lines(lines: &[&str]) -> String { - lines - .iter() - .flat_map(|line| line.split_whitespace()) - .collect::<Vec<_>>() - .join(" ") - .to_lowercase() -} - -fn transcript_control_tail(line: &str) -> bool { - let lower = line.to_lowercase(); - lower.contains("q to quit") - || lower.contains("esc to edit") - || lower.contains("esc/← to edit") - || lower.contains("edit message") -} diff --git a/src/detect/agents/cursor.rs b/src/detect/agents/cursor.rs deleted file mode 100644 index f0f00b9b..00000000 --- a/src/detect/agents/cursor.rs +++ /dev/null @@ -1,79 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - // Blocked - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working - if lower.contains("ctrl+c to stop") { - return AgentState::Working; - } - if has_cursor_spinner(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - if lower.contains("waiting for approval") - && lower.contains("run this command?") - && (lower.contains("run (once) (y)") || lower.contains("skip (esc or n)")) - { - return true; - } - - if lower.contains("(y) (enter)") - || lower.contains("keep (n)") - || lower.contains("skip (esc or n)") - { - return true; - } - - content.lines().any(|line| { - let line = line.trim().to_lowercase(); - let has_yes_action = line.contains("(y)"); - has_yes_action - && (line.contains("allow") - || line.contains("run (once)") - || line.contains("→ run") - || line.starts_with("run ")) - }) -} - -/// Cursor status line: spinner glyphs followed by a live action label. -pub(in crate::detect) fn has_cursor_spinner(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim_start(); - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - return false; - }; - let rest = chars.as_str().trim_start(); - - if matches!(first, '⬡' | '⬢') { - return cursor_status_word_is_active(rest); - } - - if ('\u{2800}'..='\u{28FF}').contains(&first) { - let rest = rest.trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c)); - return cursor_status_word_is_active(rest.trim_start()); - } - - false - }) -} - -fn cursor_status_word_is_active(rest: &str) -> bool { - let Some(word) = rest.split_whitespace().next() else { - return false; - }; - word.trim_end_matches(|c: char| !c.is_alphabetic()) - .to_ascii_lowercase() - .ends_with("ing") -} diff --git a/src/detect/agents/droid.rs b/src/detect/agents/droid.rs deleted file mode 100644 index 78dc811e..00000000 --- a/src/detect/agents/droid.rs +++ /dev/null @@ -1,39 +0,0 @@ -use super::super::{has_braille_spinner, AgentState}; - -/// Droid detection. -/// -/// Working: braille spinner line (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) + "Thinking..." + "(Press ESC to stop)" -/// Blocked: EXECUTE prompt with selection box ("Yes, allow" / "No, cancel") + -/// "Use ↑↓ to navigate, Enter to select" -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working: braille spinner character at start of a line + "Thinking..." - // The braille chars (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) are very specific — won't appear in normal content - if has_braille_spinner(content) && lower.contains("esc to stop") { - return AgentState::Working; - } - // Fallback: "ESC to stop" alone is still a strong signal (it's UI chrome) - if lower.contains("esc to stop") { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - // Primary (AND): structural keyword + chrome text = certain - let has_execute = content.contains("EXECUTE"); - let has_selection_chrome = lower.contains("enter to select") - || lower.contains("↑↓ to navigate") - || lower.contains("esc to cancel"); - let has_selection_options = lower.contains("> yes, allow") || lower.contains("> no, cancel"); - - (has_execute && (has_selection_chrome || has_selection_options)) - || (has_selection_chrome && has_selection_options) -} diff --git a/src/detect/agents/gemini.rs b/src/detect/agents/gemini.rs deleted file mode 100644 index 86cf3bca..00000000 --- a/src/detect/agents/gemini.rs +++ /dev/null @@ -1,31 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - let lower = content.to_lowercase(); - // Working - if lower.contains("esc to cancel") { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_choice = lower.contains("yes") || lower.contains("no"); - - content.contains("│ Apply this change") - || content.contains("│ Allow execution") - || (has_choice - && (lower.contains("waiting for user confirmation") - || content.contains("│ Do you want to proceed") - || lower.contains("do you want to proceed?"))) - || content.lines().any(|line| { - let line = line.trim().to_ascii_lowercase(); - line.starts_with("❯") && (line.contains("yes") || line.contains("allow")) - }) -} diff --git a/src/detect/agents/github_copilot.rs b/src/detect/agents/github_copilot.rs deleted file mode 100644 index 0eece95b..00000000 --- a/src/detect/agents/github_copilot.rs +++ /dev/null @@ -1,28 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - // Blocked - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working - if lower.contains("esc to cancel") - || lower.contains("esc cancel") - || lower.contains("esc again to cancel") - { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - lower.contains("esc to cancel") - && (lower.contains("enter to select") - || lower.contains("enter to confirm") - || lower.contains("enter to submit")) -} diff --git a/src/detect/agents/grok.rs b/src/detect/agents/grok.rs deleted file mode 100644 index 8a5834a2..00000000 --- a/src/detect/agents/grok.rs +++ /dev/null @@ -1,37 +0,0 @@ -use super::super::{has_braille_spinner, AgentState}; - -/// 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". -pub(super) fn detect(content: &str) -> AgentState { - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - let lower = content.to_lowercase(); - 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 -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_scope_selector = lower.contains("use ← → to choose permission whitelist scope") - || lower.contains("←/→:scope"); - has_scope_selector && lower.contains("yes, proceed") && lower.contains("no, reject") -} diff --git a/src/detect/agents/hermes.rs b/src/detect/agents/hermes.rs deleted file mode 100644 index 474dda5c..00000000 --- a/src/detect/agents/hermes.rs +++ /dev/null @@ -1,30 +0,0 @@ -use super::super::AgentState; - -/// Hermes Agent detection. -/// -/// Hermes shows a bottom status bar while turns are active and modal approval -/// dialogs for dangerous terminal commands. Prefer the modal controls for -/// blocked detection, then the live interrupt/status controls for working. -pub(super) fn detect(content: &str) -> AgentState { - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - let lower = content.to_lowercase(); - if lower.contains("msg=interrupt") || lower.contains("ctrl+c cancel") { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_approval_options = lower.contains("allow once") - && lower.contains("allow for this session") - && lower.contains("deny"); - let has_approval_controls = lower.contains("enter to confirm") - || lower.contains("↑/↓ to select") - || lower.contains("show full command"); - (lower.contains("dangerous command") || has_approval_options) && has_approval_controls -} diff --git a/src/detect/agents/kilo.rs b/src/detect/agents/kilo.rs deleted file mode 100644 index 46a5601a..00000000 --- a/src/detect/agents/kilo.rs +++ /dev/null @@ -1,13 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - if content.to_lowercase().contains("esc interrupt") { - return AgentState::Working; - } - - super::opencode::detect(content) -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - super::opencode::has_visible_blocker(content) -} diff --git a/src/detect/agents/kimi.rs b/src/detect/agents/kimi.rs deleted file mode 100644 index 9d3821ed..00000000 --- a/src/detect/agents/kimi.rs +++ /dev/null @@ -1,97 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - if has_kimi_blocked_prompt(content) { - return AgentState::Blocked; - } - - if has_kimi_working_status(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - has_current_approval_panel(content) || has_question_panel(content) -} - -pub(super) fn has_visible_working(content: &str) -> bool { - has_kimi_working_status(content) -} - -fn has_kimi_blocked_prompt(content: &str) -> bool { - if has_visible_blocker(content) { - return true; - } - - let lower = content.to_lowercase(); - lower.contains("requesting approval") - && (lower.contains("approve once") || lower.contains("approve for this session")) - && lower.contains("reject") - && (lower.contains("1/2/3/4 choose") || lower.contains("↵ confirm")) -} - -fn has_kimi_working_status(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim(); - if matches!( - trimmed, - "🌕" | "🌖" | "🌗" | "🌘" | "🌑" | "🌒" | "🌓" | "🌔" - ) { - return true; - } - - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !('\u{2800}'..='\u{28FF}').contains(&first) { - return false; - } - - let rest = chars - .as_str() - .trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) - .trim_start() - .to_lowercase(); - rest.starts_with("thinking...") - || rest.starts_with("working...") - || rest.starts_with("using ") - }) -} - -fn has_current_approval_panel(content: &str) -> bool { - let lower = content.to_lowercase(); - has_approval_title(&lower) - && has_numeric_choose_hint(&lower) - && lower.contains("↵ confirm") - && (lower.contains("approve") || lower.contains("reject") || lower.contains("revise")) -} - -fn has_approval_title(lower_content: &str) -> bool { - lower_content.contains("run this command?") - || lower_content.contains("write this file?") - || lower_content.contains("apply these edits?") - || lower_content.contains("stop this task?") - || lower_content.contains("ready to build with this plan?") - || lower_content.lines().any(|line| { - let trimmed = line.trim_start_matches(|c: char| c == '▶' || c.is_whitespace()); - trimmed.starts_with("approve ") && trimmed.ends_with('?') - }) -} - -fn has_question_panel(content: &str) -> bool { - let lower = content.to_lowercase(); - content.lines().any(|line| line.trim() == "question") - && content - .lines() - .any(|line| line.trim_start().starts_with("? ")) - && lower.contains("↑↓ select") - && (lower.contains("↵ choose") || lower.contains("↵ toggle") || lower.contains("↵ save")) - && lower.contains("esc cancel") -} - -fn has_numeric_choose_hint(lower_content: &str) -> bool { - lower_content.contains(" choose") && lower_content.contains('1') && lower_content.contains('2') -} diff --git a/src/detect/agents/kiro.rs b/src/detect/agents/kiro.rs deleted file mode 100644 index 889f5315..00000000 --- a/src/detect/agents/kiro.rs +++ /dev/null @@ -1,60 +0,0 @@ -use super::super::AgentState; - -/// Kiro CLI detection. -/// -/// Kiro exposes reliable working and idle terminal markers. Tool approval -/// prompts render with stable approval wording and an action menu. -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - if lower.contains("kiro is working") - || (lower.contains("esc to cancel") && has_kiro_tool_spinner(content)) - { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower_content = content.to_lowercase(); - has_tool_approval_prompt(&lower_content) || has_subagent_approval_prompt(&lower_content) -} - -fn has_tool_approval_prompt(lower_content: &str) -> bool { - let has_approval_request = lower_content.contains("requires approval"); - let has_approval_actions = lower_content.contains("yes, single permission") - || lower_content.contains("trust, always allow") - || lower_content.contains("no (tab to edit)") - || lower_content.contains("esc to close"); - has_approval_request && has_approval_actions -} - -fn has_subagent_approval_prompt(lower_content: &str) -> bool { - let has_approval_request = (lower_content.contains("tool approval") - || lower_content.contains("tool approvals")) - && lower_content.contains("pending from subagents"); - let has_approval_actions = lower_content.contains("approve all pending") - || lower_content.contains("configure individually") - || lower_content.contains("exit (cancel subagents)"); - has_approval_request && has_approval_actions -} - -fn has_kiro_tool_spinner(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim_start(); - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !matches!(first, '◔' | '◑' | '◕' | '●') { - return false; - } - let rest = chars.as_str().trim_start(); - rest.chars().next().is_some_and(char::is_alphabetic) - }) -} diff --git a/src/detect/agents/mod.rs b/src/detect/agents/mod.rs deleted file mode 100644 index 72aaf674..00000000 --- a/src/detect/agents/mod.rs +++ /dev/null @@ -1,105 +0,0 @@ -pub(super) mod amp; -pub(super) mod antigravity; -pub(super) mod claude_code; -pub(super) mod cline; -pub(super) mod codex; -pub(super) mod cursor; -pub(super) mod droid; -pub(super) mod gemini; -pub(super) mod github_copilot; -pub(super) mod grok; -pub(super) mod hermes; -pub(super) mod kilo; -pub(super) mod kimi; -pub(super) mod kiro; -pub(super) mod opencode; -pub(super) mod pi; -pub(super) mod qodercli; - -use super::{Agent, AgentDetection, AgentState}; - -pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection { - let state = match agent { - Agent::Pi => pi::detect(screen_content), - Agent::Claude => claude_code::detect(screen_content), - Agent::Codex => codex::detect(screen_content), - Agent::Gemini => gemini::detect(screen_content), - Agent::Cursor => cursor::detect(screen_content), - Agent::Antigravity => antigravity::detect(screen_content), - Agent::Cline => cline::detect(screen_content), - Agent::OpenCode => opencode::detect(screen_content), - Agent::GithubCopilot => github_copilot::detect(screen_content), - Agent::Kimi => kimi::detect(screen_content), - Agent::Kiro => kiro::detect(screen_content), - Agent::Droid => droid::detect(screen_content), - Agent::Amp => amp::detect(screen_content), - Agent::Grok => grok::detect(screen_content), - Agent::Hermes => hermes::detect(screen_content), - Agent::Kilo => kilo::detect(screen_content), - Agent::Qodercli => qodercli::detect(screen_content), - }; - - let skip_state_update = should_skip_state_update(agent, screen_content); - if skip_state_update { - return AgentDetection { - state, - skip_state_update: true, - visible_blocker: false, - visible_working: false, - }; - } - - AgentDetection { - state, - skip_state_update: false, - visible_blocker: has_visible_blocker(agent, screen_content), - visible_working: has_visible_working(agent, screen_content, state), - } -} - -pub(super) fn should_skip_state_update(agent: Agent, content: &str) -> bool { - match agent { - Agent::Claude => claude_code::is_transcript_viewer(content), - Agent::Codex => codex::is_transcript_viewer(content), - _ => false, - } -} - -fn has_visible_blocker(agent: Agent, content: &str) -> bool { - match agent { - // Strong visible blockers are opt-in because this flag can override - // hook authority. Plain blocked heuristics remain valid fallback state, - // but they must not become hook overrides unless the current UI chrome - // is known to be structural and live. - Agent::Claude => claude_code::has_visible_blocker(content), - Agent::Codex => codex::has_visible_blocker(content), - Agent::Gemini => gemini::has_visible_blocker(content), - Agent::Cursor => cursor::has_visible_blocker(content), - Agent::Antigravity => antigravity::has_visible_blocker(content), - Agent::Cline => cline::has_visible_blocker(content), - Agent::OpenCode => opencode::has_visible_blocker(content), - Agent::GithubCopilot => github_copilot::has_visible_blocker(content), - Agent::Kimi => kimi::has_visible_blocker(content), - Agent::Kiro => kiro::has_visible_blocker(content), - Agent::Droid => droid::has_visible_blocker(content), - Agent::Amp => amp::has_visible_blocker(content), - Agent::Grok => grok::has_visible_blocker(content), - Agent::Hermes => hermes::has_visible_blocker(content), - Agent::Kilo => kilo::has_visible_blocker(content), - Agent::Qodercli => qodercli::has_visible_blocker(content), - _ => false, - } -} - -fn has_visible_working(agent: Agent, content: &str, state: AgentState) -> bool { - if state != AgentState::Working { - return false; - } - - match agent { - Agent::Claude => claude_code::has_working_chrome(content), - Agent::Codex => codex::has_visible_working(content), - Agent::Kimi => kimi::has_visible_working(content), - _ => false, - } -} diff --git a/src/detect/agents/opencode.rs b/src/detect/agents/opencode.rs deleted file mode 100644 index ab8a424f..00000000 --- a/src/detect/agents/opencode.rs +++ /dev/null @@ -1,58 +0,0 @@ -use super::super::{has_interrupt_pattern, AgentState}; - -pub(super) fn detect(content: &str) -> AgentState { - // Blocked - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working - if has_interrupt_pattern(&content.to_lowercase()) - || has_opencode_interrupt_footer(content) - || has_opencode_progress_run(content) - { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - content.contains("△ Permission required") || has_opencode_question_prompt(content) -} - -fn has_opencode_question_prompt(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_enter_action = lower.contains("enter confirm") - || lower.contains("enter submit") - || lower.contains("enter toggle"); - let has_question_nav = content.contains("↑↓ select") || content.contains("⇆ tab"); - - lower.contains("esc dismiss") && has_enter_action && has_question_nav -} - -fn has_opencode_interrupt_footer(content: &str) -> bool { - content.lines().any(|line| { - let lower = line.to_lowercase(); - if !(lower.contains("esc interrupt") || lower.contains("esc again to interrupt")) { - return false; - } - - lower.contains("opencode") - }) -} - -fn has_opencode_progress_run(line: &str) -> bool { - let mut run = 0usize; - for ch in line.chars() { - if matches!(ch, '■' | '⬝') { - run += 1; - if run >= 4 { - return true; - } - } else { - run = 0; - } - } - false -} diff --git a/src/detect/agents/pi.rs b/src/detect/agents/pi.rs deleted file mode 100644 index 272231f5..00000000 --- a/src/detect/agents/pi.rs +++ /dev/null @@ -1,9 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - // pi shows "Working..." when the agent is processing - if content.contains("Working...") { - return AgentState::Working; - } - AgentState::Idle -} diff --git a/src/detect/agents/qodercli.rs b/src/detect/agents/qodercli.rs deleted file mode 100644 index d2802464..00000000 --- a/src/detect/agents/qodercli.rs +++ /dev/null @@ -1,86 +0,0 @@ -use super::super::AgentState; - -/// Qodercli detection. -/// -/// Qodercli is a Node.js coding-agent CLI. It surfaces a confirmation prompt -/// while awaiting tool approval and a braille spinner while working. -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working: explicit "(esc to cancel, …)" hint or an active spinner row. - if has_qodercli_working_hint(&lower) || has_qodercli_spinner_row(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -/// Working hints qodercli prints alongside the spinner while the model is -/// responding. The "(esc to cancel, …)" suffix is unique to qodercli's loading -/// indicator and survives even when the spinner glyph is masked (e.g. by -/// a hook icon). -fn has_qodercli_working_hint(lower_content: &str) -> bool { - lower_content.contains("(esc to cancel,") -} - -/// Strict spinner-row detection for qodercli. -/// -/// Matches a line whose first non-whitespace glyph is a braille pattern -/// (U+2800–U+28FF, the cli-spinners "dots" set qodercli renders), followed by -/// a space and at least one alphabetic character on the same line. This avoids -/// flagging the pane as Working when the scrollback merely contains a stale -/// braille glyph from an earlier frame. -fn has_qodercli_spinner_row(content: &str) -> bool { - for line in content.lines() { - let trimmed = line.trim_start(); - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - continue; - }; - if !('\u{2800}'..='\u{28FF}').contains(&first) { - continue; - } - let rest: String = chars.collect(); - if rest.starts_with(' ') && rest.chars().any(|c| c.is_alphabetic()) { - return true; - } - } - false -} - -/// Blocked patterns specific to qodercli. -/// -/// Mirrors the helper structure used by Claude blocked prompt matching so the -/// pattern surface stays a single, easy-to-extend list. -/// -/// Covered states: -/// * Tool-call confirmation banners ("Waiting for user confirmation", -/// "Awaiting approval"). -/// * The "Permission Required / Allow once or always?" approval dialog. -/// * The `ask-user` tool's interactive prompt. "Asking User" is the dialog's -/// stable BaseTabDialog title and covers every form (single-select, -/// multi-select, free-form input, review tab). The "Enter your response" -/// placeholder and "Review your answers:" review heading are kept as -/// defensive fallbacks in case the title row scrolls off-screen. -/// * The interactive shell waiting hint emitted by qodercli when an agent -/// spawns a shell that is now parked for user keystrokes. -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower_content = content.to_lowercase(); - (lower_content.contains("waiting for user confirmation") - && (lower_content.contains("yes") - || lower_content.contains("no") - || lower_content.contains("allow") - || lower_content.contains("reject"))) - || (lower_content.contains("awaiting approval") - && (lower_content.contains("allow") || lower_content.contains("reject"))) - || lower_content.contains("permission required") - || lower_content.contains("allow once or always?") - || lower_content.contains("asking user") - || lower_content.contains("enter your response") - || lower_content.contains("review your answers:") - || lower_content.contains("shell awaiting input") -} |
