aboutsummaryrefslogtreecommitdiff
path: root/src/detect/agents
diff options
context:
space:
mode:
Diffstat (limited to 'src/detect/agents')
-rw-r--r--src/detect/agents/amp.rs46
-rw-r--r--src/detect/agents/antigravity.rs76
-rw-r--r--src/detect/agents/claude_code.rs434
-rw-r--r--src/detect/agents/cline.rs19
-rw-r--r--src/detect/agents/codex.rs258
-rw-r--r--src/detect/agents/cursor.rs79
-rw-r--r--src/detect/agents/droid.rs39
-rw-r--r--src/detect/agents/gemini.rs31
-rw-r--r--src/detect/agents/github_copilot.rs28
-rw-r--r--src/detect/agents/grok.rs37
-rw-r--r--src/detect/agents/hermes.rs30
-rw-r--r--src/detect/agents/kilo.rs13
-rw-r--r--src/detect/agents/kimi.rs97
-rw-r--r--src/detect/agents/kiro.rs60
-rw-r--r--src/detect/agents/mod.rs105
-rw-r--r--src/detect/agents/opencode.rs58
-rw-r--r--src/detect/agents/pi.rs9
-rw-r--r--src/detect/agents/qodercli.rs86
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")
-}