aboutsummaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/detect/agents/amp.rs41
-rw-r--r--src/detect/agents/antigravity.rs73
-rw-r--r--src/detect/agents/claude_code.rs161
-rw-r--r--src/detect/agents/cline.rs22
-rw-r--r--src/detect/agents/codex.rs158
-rw-r--r--src/detect/agents/cursor.rs75
-rw-r--r--src/detect/agents/droid.rs39
-rw-r--r--src/detect/agents/gemini.rs28
-rw-r--r--src/detect/agents/github_copilot.rs24
-rw-r--r--src/detect/agents/grok.rs36
-rw-r--r--src/detect/agents/hermes.rs26
-rw-r--r--src/detect/agents/kimi.rs48
-rw-r--r--src/detect/agents/kiro.rs41
-rw-r--r--src/detect/agents/mod.rs86
-rw-r--r--src/detect/agents/opencode.rs25
-rw-r--r--src/detect/agents/pi.rs9
-rw-r--r--src/detect/agents/qodercli.rs99
-rw-r--r--src/detect/mod.rs (renamed from src/detect.rs)1034
-rw-r--r--src/terminal/state.rs134
19 files changed, 1322 insertions, 837 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")
+}
diff --git a/src/detect.rs b/src/detect/mod.rs
index a38251ce..6173415b 100644
--- a/src/detect.rs
+++ b/src/detect/mod.rs
@@ -3,6 +3,8 @@
//! Each pane's live bottom-of-buffer text is read periodically and matched
//! against known agent output patterns to determine state.
+mod agents;
+
/// The detected state of a terminal pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentState {
@@ -172,436 +174,66 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio
visible_working: false,
};
};
- let state = match agent {
- Agent::Pi => detect_pi(screen_content),
- Agent::Claude => detect_claude(screen_content),
- Agent::Codex => detect_codex(screen_content),
- Agent::Gemini => detect_gemini(screen_content),
- Agent::Cursor => detect_cursor(screen_content),
- Agent::Antigravity => detect_antigravity(screen_content),
- Agent::Cline => detect_cline(screen_content),
- Agent::OpenCode => detect_opencode(screen_content),
- Agent::GithubCopilot => detect_github_copilot(screen_content),
- Agent::Kimi => detect_kimi(screen_content),
- Agent::Kiro => detect_kiro(screen_content),
- Agent::Droid => detect_droid(screen_content),
- Agent::Amp => detect_amp(screen_content),
- Agent::Grok => detect_grok(screen_content),
- Agent::Hermes => detect_hermes(screen_content),
- Agent::Qodercli => detect_qodercli(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),
- }
+ agents::detect(agent, screen_content)
}
// ---------------------------------------------------------------------------
// Per-agent detectors
// ---------------------------------------------------------------------------
+#[cfg(test)]
fn detect_pi(content: &str) -> AgentState {
- // pi shows "Working..." when the agent is processing
- if content.contains("Working...") {
- return AgentState::Working;
- }
- AgentState::Idle
+ detect_state(Some(Agent::Pi), content)
}
-/// 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)
-/// ```
+#[cfg(test)]
fn detect_claude(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_claude_working_chrome(content) {
- return AgentState::Working;
- }
-
- AgentState::Idle
+ detect_state(Some(Agent::Claude), content)
}
+#[cfg(test)]
fn detect_codex(content: &str) -> AgentState {
- let lower = content.to_lowercase();
-
- // Blocked patterns
- if lower.contains("press enter to confirm or esc to cancel")
- || lower.contains("enter to submit answer")
- || lower.contains("enter to submit all")
- || lower.contains("allow command?")
- || lower.contains("[y/n]")
- || lower.contains("yes (y)")
- {
- return AgentState::Blocked;
- }
- if has_confirmation_prompt(&lower) {
- return AgentState::Blocked;
- }
-
- // Working
- if has_interrupt_pattern(&lower) || has_codex_working_header(content) {
- return AgentState::Working;
- }
-
- AgentState::Idle
+ detect_state(Some(Agent::Codex), content)
}
+#[cfg(test)]
fn detect_gemini(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
+ detect_state(Some(Agent::Gemini), content)
}
+#[cfg(test)]
fn detect_cursor(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
+ detect_state(Some(Agent::Cursor), content)
}
+#[cfg(test)]
fn detect_antigravity(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
+ detect_state(Some(Agent::Antigravity), content)
}
+#[cfg(test)]
fn detect_cline(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
+ detect_state(Some(Agent::Cline), content)
}
+#[cfg(test)]
fn detect_opencode(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
+ detect_state(Some(Agent::OpenCode), content)
}
+#[cfg(test)]
fn detect_github_copilot(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
+ detect_state(Some(Agent::GithubCopilot), content)
}
+#[cfg(test)]
fn detect_kimi(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 ")
- })
+ detect_state(Some(Agent::Kimi), content)
}
-/// 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.
-fn detect_kiro(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
-}
-
-/// 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
+#[cfg(test)]
fn detect_droid(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
-}
-
-/// 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
-/// ```
-fn detect_amp(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
-}
-
-/// 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".
-fn detect_grok(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
-}
-
-/// 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.
-fn detect_hermes(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
+ detect_state(Some(Agent::Droid), content)
}
/// Check for braille spinner characters at the start of a line.
@@ -618,117 +250,9 @@ fn has_braille_spinner(content: &str) -> bool {
false
}
-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)
- })
-}
-
-/// 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.
+#[cfg(test)]
fn detect_qodercli(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 [`detect_claude`]: 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 [`has_claude_blocked_prompt`] 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")
+ detect_state(Some(Agent::Qodercli), content)
}
// ---------------------------------------------------------------------------
@@ -747,39 +271,6 @@ fn has_confirmation_prompt(lower_content: &str) -> bool {
false
}
-/// 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")
- })
-}
-
/// Check for "❯" followed by numbered options like "1."
fn has_selection_prompt(content: &str) -> bool {
for line in content.lines() {
@@ -794,300 +285,26 @@ fn has_selection_prompt(content: &str) -> bool {
false
}
-/// Check for "esc" + "interrupt" pattern
+/// Check for real interrupt hints, not unrelated "esc" and "interrupt" text.
fn has_interrupt_pattern(lower_content: &str) -> bool {
lower_content.contains("esc to interrupt")
|| lower_content.contains("ctrl+c to interrupt")
- || (lower_content.contains("esc") && lower_content.contains("interrupt"))
-}
-
-/// 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.
-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
-}
-
-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 => has_claude_visible_blocker(content),
- Agent::Codex => has_codex_visible_blocker(content),
- _ => false,
- }
-}
-
-fn has_claude_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"))
-}
-
-fn has_codex_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- lower.contains("press enter to confirm or esc to cancel")
- || lower.contains("enter to submit answer")
- || lower.contains("enter to submit all")
- || lower.contains("allow command?")
-}
-
-fn has_visible_idle(agent: Agent, content: &str, state: AgentState) -> bool {
- if state != AgentState::Idle {
- return false;
- }
-
- match agent {
- Agent::Claude => has_claude_prompt_box(content),
- Agent::Codex => has_codex_prompt(content),
- _ => false,
- }
-}
-
-fn has_visible_working(agent: Agent, content: &str, state: AgentState) -> bool {
- if state != AgentState::Working {
- return false;
- }
-
- match agent {
- Agent::Claude => has_claude_working_chrome(content),
- Agent::Codex => has_codex_visible_working(content),
- _ => false,
- }
-}
-
-fn has_codex_visible_working(content: &str) -> bool {
- let lines: Vec<&str> = content.lines().collect();
- let Some(working_index) = lines.iter().rposition(|line| codex_live_working_line(line)) else {
- return false;
- };
-
- lines[working_index + 1..].iter().all(|line| {
- let trimmed = line.trim_start();
- !trimmed.starts_with('•')
- && !trimmed.starts_with('■')
- && !trimmed.starts_with('✗')
- && !trimmed.starts_with('✓')
- })
-}
-
-fn has_codex_working_header(content: &str) -> bool {
- content.lines().any(codex_working_status_line)
-}
-
-fn codex_live_working_line(line: &str) -> bool {
- 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_content.contains("press esc to interrupt")
}
-fn codex_working_status_line(line: &str) -> bool {
- let trimmed = line.trim_start();
- trimmed.starts_with('•')
- && (trimmed.contains("Working (") || trimmed.contains("Waiting for background terminal ("))
-}
-
-fn has_codex_prompt(content: &str) -> bool {
- content.lines().any(|line| {
- let trimmed = line.trim_start();
- trimmed == "›" || trimmed.starts_with("› ")
- })
-}
-
-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.
+#[cfg(test)]
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")
-}
-
-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();
- cursor_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
+ agents::cursor::has_cursor_spinner(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_claude_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)
-}
-
-/// Extract content above Claude's prompt box.
-/// The prompt box is two ─── border lines with ❯ between them.
+#[cfg(test)]
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 has_claude_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('❯'))
-}
-
-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
+ agents::claude_code::content_above_prompt_box(content)
}
-fn is_horizontal_rule(line: &str) -> bool {
- let trimmed = line.trim();
- !trimmed.is_empty() && trimmed.chars().all(|c| c == '─')
+#[cfg(test)]
+fn has_spinner_activity(content: &str) -> bool {
+ agents::claude_code::has_spinner_activity(content)
}
// ---------------------------------------------------------------------------
@@ -1312,6 +529,56 @@ mod tests {
std::env::temp_dir().join(unique)
}
+ #[test]
+ fn moved_agent_detection_routes_through_production_dispatch() {
+ let cases = [
+ (Agent::Pi, "Working...", AgentState::Working),
+ (
+ Agent::Claude,
+ "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────",
+ AgentState::Working,
+ ),
+ (
+ Agent::Codex,
+ "generating code\nesc to interrupt",
+ AgentState::Working,
+ ),
+ (Agent::Gemini, "Esc to cancel", AgentState::Working),
+ (Agent::Cursor, "Ctrl+C to stop", AgentState::Working),
+ (
+ Agent::Antigravity,
+ "⠋ Thinking through the request",
+ AgentState::Working,
+ ),
+ (Agent::Cline, "Reading project files", AgentState::Working),
+ (
+ Agent::OpenCode,
+ "running tool\nesc to interrupt",
+ AgentState::Working,
+ ),
+ (Agent::GithubCopilot, "Esc cancel", AgentState::Working),
+ (Agent::Kimi, "🌕", AgentState::Working),
+ (Agent::Kiro, "Kiro is working", AgentState::Working),
+ (Agent::Droid, "Press ESC to stop", AgentState::Working),
+ (Agent::Amp, "Esc to cancel", AgentState::Working),
+ (
+ Agent::Grok,
+ "⠋ Waiting… 1.8s\nCtrl+c:cancel Ctrl+Enter:interject",
+ AgentState::Working,
+ ),
+ (Agent::Hermes, "msg=interrupt", AgentState::Working),
+ (Agent::Qodercli, "\u{280B} Thinking...", AgentState::Working),
+ ];
+
+ for (agent, screen, expected) in cases {
+ assert_eq!(
+ detect_agent(Some(agent), screen).state,
+ expected,
+ "production dispatch for {agent:?}"
+ );
+ }
+ }
+
// ---- Agent identification ----
#[test]
@@ -1732,6 +999,28 @@ mod tests {
}
#[test]
+ fn claude_interrupted_permission_prompt_box_is_visible_idle() {
+ let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ assert!(!detection.visible_working);
+ assert!(!detection.visible_blocker);
+ }
+
+ #[test]
+ fn claude_spinner_after_interrupted_permission_is_visible_working() {
+ let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n❯ test\n\n✢ Garnishing… (1s · thinking with high effort)\n ⎿ Tip: Run claude --continue or claude --resume to resume a conversation\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
+ assert!(!detection.visible_idle);
+ assert!(!detection.visible_blocker);
+ }
+
+ #[test]
fn claude_separators_without_prompt_are_not_visible_idle() {
let screen = "Task complete.\n─────────────\nplain text\n─────────────";
let detection = detect_agent(Some(Agent::Claude), screen);
@@ -1746,6 +1035,7 @@ mod tests {
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
@@ -1803,6 +1093,16 @@ mod tests {
}
#[test]
+ fn codex_replayed_transcript_weak_blocked_text_above_prompt_is_idle() {
+ let screen = "Codex\nBlocked signals in src/detect/agents/codex.rs:6: confirm footer, submit answer/all, allow command, [y/n], yes (y), or generic confirmation.\n\nLikely false positives: [y/n] and generic confirmation prose still mark Blocked.\n\n• Agent thread 019e7670-ba31-7641-b6e0-545c101de8c3 is closed. Replaying saved transcript.\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master · gpt-5.5 default · Context 37% used";
+ let detection = detect_agent(Some(Agent::Codex), screen);
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ assert!(!detection.visible_blocker);
+ }
+
+ #[test]
fn codex_generic_confirmation_prompt_is_not_visible_blocker() {
let detection = detect_agent(
Some(Agent::Codex),
@@ -1876,6 +1176,78 @@ mod tests {
}
#[test]
+ fn codex_live_working_status_above_current_prompt_stays_working() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "• Ran git diff --name-status v0.6.3..HEAD\n └ A website/src/pages/releases/index.astro\n\n• Working (28s • esc to interrupt)\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 96% left",
+ );
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn codex_background_terminal_status_above_current_prompt_stays_working() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "• Ran just check\n └ test output...\n\n• Working (1m 36s) · 1 background terminal running · /ps to view · /stop to close\n\n\n› Run /review on my current changes",
+ );
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn codex_reviewing_approval_request_is_visible_working() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "• Reviewing approval request (22s • esc to interrupt)\n └ /bin/zsh -lc 'rm -rf /tmp/codex-rm-test'\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
+ );
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn codex_booting_mcp_server_is_visible_working() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "• Booting MCP server: codex_apps (7s • esc to interrupt)\n\n\n› ok spawn one again please\n\n ~/Projects/herdr · master",
+ );
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn codex_stale_working_status_above_current_idle_prompt_is_idle() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› `/feedback` to report the issue.\n\n\n › Working\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used\n\n\n■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 95% left",
+ );
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ assert!(!detection.visible_working);
+ }
+
+ #[test]
+ fn codex_pasted_transcript_in_current_prompt_does_not_trigger_working() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "› so there is a problem\n\n\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly\n 36% …\n\n wdyt\n\n\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly 36% …",
+ );
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ assert!(!detection.visible_working);
+ }
+
+ #[test]
fn codex_background_terminal_wait_is_visible_working() {
let detection = detect_agent(
Some(Agent::Codex),
@@ -1905,8 +1277,9 @@ mod tests {
"• Working (17s • esc to interrupt)\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}",
);
- assert_eq!(detection.state, AgentState::Working);
+ assert_eq!(detection.state, AgentState::Idle);
assert!(!detection.visible_working);
+ assert!(detection.visible_idle);
}
#[test]
@@ -1916,8 +1289,27 @@ mod tests {
"• Waiting for background terminal (0s • esc to …\n └ cargo test -p codex-core\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}",
);
- assert_eq!(detection.state, AgentState::Working);
+ assert_eq!(detection.state, AgentState::Idle);
assert!(!detection.visible_working);
+ assert!(detection.visible_idle);
+ }
+
+ #[test]
+ fn codex_queued_follow_up_keeps_active_working_status() {
+ let cases = [
+ "• Working (9m 24s • esc to interrupt)\n\n • Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
+ "• Working (9m 24s • esc to interrupt)\n\n• Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
+ "• Working (4s • esc to interrupt)\n\n • Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
+ "• Working (4s • esc to interrupt)\n\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
+ ];
+
+ for screen in cases {
+ let detection = detect_agent(Some(Agent::Codex), screen);
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
+ assert!(!detection.visible_idle);
+ }
}
#[test]
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index c1d17db5..bcf5737a 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -4,8 +4,8 @@ use std::time::{Duration, Instant};
// Effective state arbitration is intentionally centralized here. Hooks are the
// default authority for agent-owned internal state, but a narrow set of strong
-// visible screen signals can veto stale non-blocked hook reports. Precedence is:
-// hook blocked > strong visible blocker > Claude visible idle > hook > fallback.
+// visible screen signals can veto stale hook reports. Precedence is:
+// strong visible blocker > visible working/idle recovery > hook > fallback.
// Process-exit updates clear matching hook authority before recomputing state.
use crate::detect::{Agent, AgentState};
@@ -583,14 +583,29 @@ impl TerminalState {
fn visible_working_overrides_hook(&self) -> bool {
self.fallback_visible_working
- && self.fallback_not_older_than_hook()
+ && self.visible_working_is_fresh_enough_for_hook()
&& self.hook_authority.as_ref().is_some_and(|authority| {
- authority.state == AgentState::Idle
+ (authority.state == AgentState::Idle || authority.state == AgentState::Blocked)
&& crate::detect::parse_agent_label(&authority.agent_label)
== self.detected_agent
})
}
+ fn visible_working_is_fresh_enough_for_hook(&self) -> bool {
+ self.fallback_not_older_than_hook()
+ || self
+ .fallback_observed_at
+ .zip(
+ self.hook_authority
+ .as_ref()
+ .map(|authority| authority.reported_at),
+ )
+ .is_some_and(|(observed_at, reported_at)| {
+ reported_at >= observed_at
+ && reported_at.duration_since(observed_at) < CLAUDE_WORKING_HOLD
+ })
+ }
+
fn visible_idle_stales_hook(&self, now: Instant) -> bool {
self.stale_hook_idle_since
.is_some_and(|since| now.duration_since(since) >= STALE_HOOK_IDLE_GRACE)
@@ -600,7 +615,7 @@ impl TerminalState {
self.fallback_visible_idle
&& self.fallback_not_older_than_hook()
&& self.hook_authority.as_ref().is_some_and(|authority| {
- authority.state == AgentState::Working
+ (authority.state == AgentState::Working || authority.state == AgentState::Blocked)
&& crate::detect::parse_agent_label(&authority.agent_label)
== self.detected_agent
})
@@ -611,7 +626,7 @@ impl TerminalState {
let visible_idle_stales_hook = self.fallback_visible_idle
&& self.fallback_not_older_than_hook()
&& self.hook_authority.as_ref().is_some_and(|authority| {
- authority.state == AgentState::Working
+ (authority.state == AgentState::Working || authority.state == AgentState::Blocked)
&& crate::detect::parse_agent_label(&authority.agent_label)
== self.detected_agent
});
@@ -684,12 +699,7 @@ impl TerminalState {
previous_presentation: EffectivePresentation,
now: Instant,
) -> Option<EffectiveStateChange> {
- let state = if self
- .hook_authority
- .as_ref()
- .is_some_and(|authority| authority.state == AgentState::Blocked)
- || self.visible_blocker_overrides_hook()
- {
+ let state = if self.visible_blocker_overrides_hook() {
AgentState::Blocked
} else if self.visible_working_overrides_hook() {
AgentState::Working
@@ -1188,28 +1198,120 @@ mod tests {
}
#[test]
- fn visible_idle_does_not_override_hook_blocked() {
+ fn recent_visible_working_holds_against_newer_claude_hook_idle() {
+ let now = Instant::now();
+ let mut terminal = test_terminal();
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Working,
+ false,
+ false,
+ true,
+ false,
+ now,
+ );
+
+ let change = terminal.set_hook_authority_with_custom_status_at(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Idle,
+ None,
+ None,
+ None,
+ None,
+ now + Duration::from_millis(100),
+ );
+
+ assert!(change.unwrap().effective_state_change.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn old_visible_working_does_not_hold_against_newer_claude_hook_idle() {
+ let now = Instant::now();
+ let mut terminal = test_terminal();
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Working,
+ false,
+ false,
+ true,
+ false,
+ now,
+ );
+
+ let change = terminal.set_hook_authority_with_custom_status_at(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Idle,
+ None,
+ None,
+ None,
+ None,
+ now + CLAUDE_WORKING_HOLD + Duration::from_millis(1),
+ );
+
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert_eq!(
+ change
+ .unwrap()
+ .effective_state_change
+ .unwrap()
+ .previous_state,
+ AgentState::Working
+ );
+ }
+
+ #[test]
+ fn visible_idle_waits_before_overriding_claude_hook_blocked() {
+ let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Claude), AgentState::Working);
- terminal.set_hook_authority(
+ terminal.set_hook_authority_with_custom_status_at(
"herdr:claude".into(),
"claude".into(),
AgentState::Blocked,
None,
+ Some("permission".into()),
+ None,
None,
+ now,
);
- let change = terminal.set_detected_state_with_visible_blocker(
+ let waiting = terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Claude),
AgentState::Idle,
false,
true,
false,
+ false,
+ now + Duration::from_millis(500),
);
+ assert!(waiting.effective_state_change.is_none());
assert_eq!(terminal.fallback_state, AgentState::Idle);
assert_eq!(terminal.state, AgentState::Blocked);
- assert!(change.is_none());
+ assert_eq!(
+ terminal.effective_custom_status().as_deref(),
+ Some("permission")
+ );
+
+ let change = terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ false,
+ now + Duration::from_millis(500) + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1),
+ );
+
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert_eq!(terminal.effective_custom_status(), None);
+ assert_eq!(
+ change.effective_state_change.unwrap().previous_state,
+ AgentState::Blocked
+ );
}
#[test]