diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 23:54:41 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 23:54:41 +0300 |
| commit | d477341e6af816ff4396042b084a0378ef437b4a (patch) | |
| tree | e332b6f7a0d23d9effd21c3ca8ab27698ed83444 /src/detect/agents | |
| parent | 8df1639048529b383358000879b5ee078a90b9f9 (diff) | |
refactor: split agent detection by agent
Move screen detection into per-agent modules and keep visible-state arbitration centralized in terminal state.
Tighten Codex prompt-scoped working detection and Claude hook/screen recovery for stale blocked and idle reports.
Add a focused just recipe for single-test runs.
refs #349
refs #352
Diffstat (limited to 'src/detect/agents')
| -rw-r--r-- | src/detect/agents/amp.rs | 41 | ||||
| -rw-r--r-- | src/detect/agents/antigravity.rs | 73 | ||||
| -rw-r--r-- | src/detect/agents/claude_code.rs | 161 | ||||
| -rw-r--r-- | src/detect/agents/cline.rs | 22 | ||||
| -rw-r--r-- | src/detect/agents/codex.rs | 158 | ||||
| -rw-r--r-- | src/detect/agents/cursor.rs | 75 | ||||
| -rw-r--r-- | src/detect/agents/droid.rs | 39 | ||||
| -rw-r--r-- | src/detect/agents/gemini.rs | 28 | ||||
| -rw-r--r-- | src/detect/agents/github_copilot.rs | 24 | ||||
| -rw-r--r-- | src/detect/agents/grok.rs | 36 | ||||
| -rw-r--r-- | src/detect/agents/hermes.rs | 26 | ||||
| -rw-r--r-- | src/detect/agents/kimi.rs | 48 | ||||
| -rw-r--r-- | src/detect/agents/kiro.rs | 41 | ||||
| -rw-r--r-- | src/detect/agents/mod.rs | 86 | ||||
| -rw-r--r-- | src/detect/agents/opencode.rs | 25 | ||||
| -rw-r--r-- | src/detect/agents/pi.rs | 9 | ||||
| -rw-r--r-- | src/detect/agents/qodercli.rs | 99 |
17 files changed, 991 insertions, 0 deletions
diff --git a/src/detect/agents/amp.rs b/src/detect/agents/amp.rs new file mode 100644 index 00000000..ad76ca27 --- /dev/null +++ b/src/detect/agents/amp.rs @@ -0,0 +1,41 @@ +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(); + + 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")); + + if has_approval_actions && (has_waiting_for_approval || has_approval_header) { + return AgentState::Blocked; + } + + if lower.contains("esc to cancel") { + return AgentState::Working; + } + + AgentState::Idle +} diff --git a/src/detect/agents/antigravity.rs b/src/detect/agents/antigravity.rs new file mode 100644 index 00000000..b504a96a --- /dev/null +++ b/src/detect/agents/antigravity.rs @@ -0,0 +1,73 @@ +use super::super::AgentState; + +pub(super) fn detect(content: &str) -> AgentState { + 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"); + if has_permission_request && (has_permission_question || has_permission_controls) { + return AgentState::Blocked; + } + + if has_antigravity_spinner(content) || has_antigravity_background_tasks(content) { + return AgentState::Working; + } + + AgentState::Idle +} + +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 new file mode 100644 index 00000000..e6633eda --- /dev/null +++ b/src/detect/agents/claude_code.rs @@ -0,0 +1,161 @@ +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(); + + // Search prompt is always idle + if content.contains("⌕ Search…") { + return AgentState::Idle; + } + + // ctrl+r toggle — don't change state + // (we return Idle as a safe default since we don't have previous state here) + if lower.contains("ctrl+r to toggle") { + return AgentState::Idle; + } + + // --- Blocked detection (full content including prompt box) --- + + if has_claude_blocked_prompt(content, &lower) { + return AgentState::Blocked; + } + + // --- Working detection (content above the prompt box) --- + + if has_working_chrome(content) { + return AgentState::Working; + } + + AgentState::Idle +} + +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + 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(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_spinner_activity(above) +} + +pub(super) fn has_prompt_box(content: &str) -> bool { + let lines: Vec<&str> = content.lines().collect(); + let Some(top_border_index) = claude_prompt_box_top_border_index(&lines) else { + return false; + }; + + lines[top_border_index + 1..] + .iter() + .take_while(|line| !is_horizontal_rule(line)) + .any(|line| line.trim_start().starts_with('❯')) +} + +/// 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?") + || 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("chat about this") + || 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_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 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(); + !trimmed.is_empty() && trimmed.chars().all(|c| c == '─') +} diff --git a/src/detect/agents/cline.rs b/src/detect/agents/cline.rs new file mode 100644 index 00000000..86b5ce01 --- /dev/null +++ b/src/detect/agents/cline.rs @@ -0,0 +1,22 @@ +use super::super::AgentState; + +pub(super) fn detect(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Blocked + if lower.contains("let cline use this tool") { + return AgentState::Blocked; + } + // [act mode] or [plan mode] followed by "yes" + if (lower.contains("[act mode]") || lower.contains("[plan mode]")) && lower.contains("yes") { + return AgentState::Blocked; + } + + // Idle + if lower.contains("cline is ready for your message") { + return AgentState::Idle; + } + + // Cline defaults to working (unlike most agents that default to idle) + AgentState::Working +} diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs new file mode 100644 index 00000000..0d23d4cb --- /dev/null +++ b/src/detect/agents/codex.rs @@ -0,0 +1,158 @@ +use super::super::{has_confirmation_prompt, has_interrupt_pattern, AgentState}; + +pub(super) fn detect(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // 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(&lower) { + return AgentState::Blocked; + } + + // Working + if has_codex_working_status_at_current_prompt(content) { + return AgentState::Working; + } + + // Idle + if has_codex_current_prompt(content) { + return AgentState::Idle; + } + + // Weak blocked patterns are too broad to scan above a visible prompt. They + // only apply when Codex does not currently show an idle prompt region. + 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 { + let lower = content.to_lowercase(); + has_codex_strong_blocked_prompt(&lower) +} + +pub(super) fn has_prompt(content: &str) -> bool { + has_codex_current_prompt(content) || content.lines().any(codex_prompt_line) +} + +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)) +} + +fn has_codex_visible_working_without_prompt(content: &str) -> bool { + content + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .is_some_and(codex_live_working_line) +} + +fn has_codex_strong_blocked_prompt(lower_content: &str) -> bool { + 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") + || lower.contains("esc to interrupt") + || lower.contains("esc…") + || 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(); + trimmed.starts_with('•') + && (trimmed.contains("Working (") + || trimmed.contains("Waiting for background terminal (") + || trimmed.contains("Reviewing approval request (") + || trimmed.contains("Booting MCP server:")) +} + +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_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") +} diff --git a/src/detect/agents/cursor.rs b/src/detect/agents/cursor.rs new file mode 100644 index 00000000..a62e9382 --- /dev/null +++ b/src/detect/agents/cursor.rs @@ -0,0 +1,75 @@ +use super::super::AgentState; + +pub(super) fn detect(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Blocked + if has_cursor_blocked_prompt(content, &lower) { + return AgentState::Blocked; + } + + // Working + if lower.contains("ctrl+c to stop") { + return AgentState::Working; + } + if has_cursor_spinner(content) { + return AgentState::Working; + } + + AgentState::Idle +} + +fn has_cursor_blocked_prompt(content: &str, lower: &str) -> bool { + if lower.contains("waiting for approval") || lower.contains("run this command?") { + 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 new file mode 100644 index 00000000..ea19da3f --- /dev/null +++ b/src/detect/agents/droid.rs @@ -0,0 +1,39 @@ +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" +/// Idle: prompt box visible, no spinner, no selection prompt +pub(super) fn detect(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Blocked: EXECUTE approval prompt with selection UI chrome + // 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"); + + if has_execute && (has_selection_chrome || has_selection_options) { + return AgentState::Blocked; + } + // Secondary: selection chrome + options together (no EXECUTE needed) + if has_selection_chrome && has_selection_options { + 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 +} diff --git a/src/detect/agents/gemini.rs b/src/detect/agents/gemini.rs new file mode 100644 index 00000000..c47355c0 --- /dev/null +++ b/src/detect/agents/gemini.rs @@ -0,0 +1,28 @@ +use super::super::{has_confirmation_prompt, AgentState}; + +pub(super) fn detect(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Blocked — explicit confirmation + if lower.contains("waiting for user confirmation") { + return AgentState::Blocked; + } + + // Blocked — box-drawing confirmation prompts + if content.contains("│ Apply this change") + || content.contains("│ Allow execution") + || content.contains("│ Do you want to proceed") + { + return AgentState::Blocked; + } + if has_confirmation_prompt(&lower) { + return AgentState::Blocked; + } + + // Working + if lower.contains("esc to cancel") { + return AgentState::Working; + } + + AgentState::Idle +} diff --git a/src/detect/agents/github_copilot.rs b/src/detect/agents/github_copilot.rs new file mode 100644 index 00000000..eed03011 --- /dev/null +++ b/src/detect/agents/github_copilot.rs @@ -0,0 +1,24 @@ +use super::super::AgentState; + +pub(super) fn detect(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Blocked + if lower.contains("esc to cancel") + && (lower.contains("enter to select") + || lower.contains("enter to confirm") + || lower.contains("enter to submit")) + { + 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 +} diff --git a/src/detect/agents/grok.rs b/src/detect/agents/grok.rs new file mode 100644 index 00000000..75b5cd35 --- /dev/null +++ b/src/detect/agents/grok.rs @@ -0,0 +1,36 @@ +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 { + 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 +} diff --git a/src/detect/agents/hermes.rs b/src/detect/agents/hermes.rs new file mode 100644 index 00000000..d6c4c97e --- /dev/null +++ b/src/detect/agents/hermes.rs @@ -0,0 +1,26 @@ +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 { + 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"); + if (lower.contains("dangerous command") || has_approval_options) && has_approval_controls { + return AgentState::Blocked; + } + + if lower.contains("msg=interrupt") || lower.contains("ctrl+c cancel") { + return AgentState::Working; + } + + AgentState::Idle +} diff --git a/src/detect/agents/kimi.rs b/src/detect/agents/kimi.rs new file mode 100644 index 00000000..73d1a8b0 --- /dev/null +++ b/src/detect/agents/kimi.rs @@ -0,0 +1,48 @@ +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 +} + +fn has_kimi_blocked_prompt(content: &str) -> bool { + 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("using ") + }) +} diff --git a/src/detect/agents/kiro.rs b/src/detect/agents/kiro.rs new file mode 100644 index 00000000..5ef1fbca --- /dev/null +++ b/src/detect/agents/kiro.rs @@ -0,0 +1,41 @@ +use super::super::AgentState; + +/// Kiro CLI detection. +/// +/// Kiro exposes reliable working and idle terminal markers. Tool approval +/// prompts render with a stable "requires approval" line and an action menu. +pub(super) fn detect(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + let has_approval_request = lower.contains("requires approval"); + let has_approval_actions = lower.contains("yes, single permission") + || lower.contains("trust, always allow") + || lower.contains("no (tab to edit)") + || lower.contains("esc to close"); + if has_approval_request && has_approval_actions { + return AgentState::Blocked; + } + + if lower.contains("kiro is working") + || (lower.contains("esc to cancel") && has_kiro_tool_spinner(content)) + { + return AgentState::Working; + } + + AgentState::Idle +} + +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 new file mode 100644 index 00000000..87b4043f --- /dev/null +++ b/src/detect/agents/mod.rs @@ -0,0 +1,86 @@ +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 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::Qodercli => qodercli::detect(screen_content), + }; + + AgentDetection { + state, + visible_blocker: has_visible_blocker(agent, screen_content, state), + visible_idle: has_visible_idle(agent, screen_content, state), + visible_working: has_visible_working(agent, screen_content, state), + } +} + +fn has_visible_blocker(agent: Agent, content: &str, state: AgentState) -> bool { + if state != AgentState::Blocked { + return false; + } + + 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), + _ => false, + } +} + +fn has_visible_idle(agent: Agent, content: &str, state: AgentState) -> bool { + if state != AgentState::Idle { + return false; + } + + match agent { + Agent::Claude => claude_code::has_prompt_box(content), + Agent::Codex => codex::has_prompt(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), + _ => false, + } +} diff --git a/src/detect/agents/opencode.rs b/src/detect/agents/opencode.rs new file mode 100644 index 00000000..5badcad9 --- /dev/null +++ b/src/detect/agents/opencode.rs @@ -0,0 +1,25 @@ +use super::super::{has_interrupt_pattern, AgentState}; + +pub(super) fn detect(content: &str) -> AgentState { + // Blocked + if content.contains("△ Permission required") || has_opencode_question_prompt(content) { + return AgentState::Blocked; + } + + // Working + if has_interrupt_pattern(&content.to_lowercase()) { + return AgentState::Working; + } + + AgentState::Idle +} + +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 +} diff --git a/src/detect/agents/pi.rs b/src/detect/agents/pi.rs new file mode 100644 index 00000000..272231f5 --- /dev/null +++ b/src/detect/agents/pi.rs @@ -0,0 +1,9 @@ +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 new file mode 100644 index 00000000..b7da356b --- /dev/null +++ b/src/detect/agents/qodercli.rs @@ -0,0 +1,99 @@ +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(); + + // Idle short-circuit: double-press confirmation hints render *over* the + // input prompt while the user briefly holds Ctrl+C / Esc. The pane is + // effectively idle there — without this, a stale spinner row above could + // still flip it to Working. + if has_qodercli_idle_override(&lower) { + return AgentState::Idle; + } + + if has_qodercli_blocked_prompt(&lower) { + 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 +} + +/// Idle override hints. Mirrors the `⌕ Search…` / `ctrl+r to toggle` shortcut +/// in Claude detection: when these UI bits are visible the pane is sitting at +/// a static prompt and should not be classified as Working or Blocked. +/// +/// Covers qodercli's "press again" exit/rewind banners. +fn has_qodercli_idle_override(lower_content: &str) -> bool { + lower_content.contains("press ctrl+c again to exit") + || lower_content.contains("press ctrl+d again to exit") + || lower_content.contains("press esc again to rewind") +} + +/// 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. +fn has_qodercli_blocked_prompt(lower_content: &str) -> bool { + lower_content.contains("waiting for user confirmation") + || lower_content.contains("awaiting approval") + || 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") +} |
