aboutsummaryrefslogtreecommitdiff
path: root/src/detect/agents/claude_code.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 23:54:41 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 23:54:41 +0300
commitd477341e6af816ff4396042b084a0378ef437b4a (patch)
treee332b6f7a0d23d9effd21c3ca8ab27698ed83444 /src/detect/agents/claude_code.rs
parent8df1639048529b383358000879b5ee078a90b9f9 (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/claude_code.rs')
-rw-r--r--src/detect/agents/claude_code.rs161
1 files changed, 161 insertions, 0 deletions
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 == '─')
+}