aboutsummaryrefslogtreecommitdiff
path: root/src/detect/agents
diff options
context:
space:
mode:
Diffstat (limited to 'src/detect/agents')
-rw-r--r--src/detect/agents/claude_code.rs77
-rw-r--r--src/detect/agents/cline.rs7
-rw-r--r--src/detect/agents/codex.rs9
-rw-r--r--src/detect/agents/droid.rs1
-rw-r--r--src/detect/agents/mod.rs8
-rw-r--r--src/detect/agents/qodercli.rs19
6 files changed, 58 insertions, 63 deletions
diff --git a/src/detect/agents/claude_code.rs b/src/detect/agents/claude_code.rs
index 1336c480..b159fede 100644
--- a/src/detect/agents/claude_code.rs
+++ b/src/detect/agents/claude_code.rs
@@ -12,17 +12,6 @@ use super::super::{has_confirmation_prompt, has_selection_prompt, AgentState};
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;
- }
-
if has_live_blocked_form(content) {
return AgentState::Blocked;
}
@@ -39,14 +28,14 @@ pub(super) fn detect(content: &str) -> AgentState {
return AgentState::Blocked;
}
- if has_prompt_box(content) {
- return AgentState::Idle;
- }
-
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)
@@ -106,15 +95,57 @@ pub(super) fn is_transcript_viewer(content: &str) -> bool {
}
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;
- };
+ prompt_box_body_lines(content)
+ .is_some_and(|lines| lines.iter().any(|line| line.trim_start().starts_with('❯')))
+}
- lines[top_border_index + 1..]
- .iter()
- .take_while(|line| !is_horizontal_rule(line))
- .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
diff --git a/src/detect/agents/cline.rs b/src/detect/agents/cline.rs
index 13509606..c0ce6f6e 100644
--- a/src/detect/agents/cline.rs
+++ b/src/detect/agents/cline.rs
@@ -1,18 +1,11 @@
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;
}
- // 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
index b77b7d25..93d2998c 100644
--- a/src/detect/agents/codex.rs
+++ b/src/detect/agents/codex.rs
@@ -14,13 +14,8 @@ pub(super) fn detect(content: &str) -> AgentState {
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.
+ // 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;
}
diff --git a/src/detect/agents/droid.rs b/src/detect/agents/droid.rs
index 52c5e087..78dc811e 100644
--- a/src/detect/agents/droid.rs
+++ b/src/detect/agents/droid.rs
@@ -5,7 +5,6 @@ use super::super::{has_braille_spinner, AgentState};
/// 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();
diff --git a/src/detect/agents/mod.rs b/src/detect/agents/mod.rs
index 704350df..72aaf674 100644
--- a/src/detect/agents/mod.rs
+++ b/src/detect/agents/mod.rs
@@ -52,7 +52,7 @@ pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection {
AgentDetection {
state,
skip_state_update: false,
- visible_blocker: has_visible_blocker(agent, screen_content, state),
+ visible_blocker: has_visible_blocker(agent, screen_content),
visible_working: has_visible_working(agent, screen_content, state),
}
}
@@ -65,11 +65,7 @@ pub(super) fn should_skip_state_update(agent: Agent, content: &str) -> bool {
}
}
-fn has_visible_blocker(agent: Agent, content: &str, state: AgentState) -> bool {
- if state != AgentState::Blocked {
- return 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,
diff --git a/src/detect/agents/qodercli.rs b/src/detect/agents/qodercli.rs
index 0a4372df..d2802464 100644
--- a/src/detect/agents/qodercli.rs
+++ b/src/detect/agents/qodercli.rs
@@ -7,14 +7,6 @@ use super::super::AgentState;
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_visible_blocker(content) {
return AgentState::Blocked;
}
@@ -27,17 +19,6 @@ pub(super) fn detect(content: &str) -> AgentState {
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