diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-09 04:24:12 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-09 04:26:19 +0300 |
| commit | eeacb45b4b6ab4446c5bea1331f743fee5e5b9f8 (patch) | |
| tree | 063e99ff0a40ce7ef00002ac00130aeb3f30f6d7 /src | |
| parent | 120001b591e64d80a557d81e3b112460b9987fc6 (diff) | |
fix: remove idle screen patterns from agent detection
Diffstat (limited to 'src')
| -rw-r--r-- | src/detect/agents/claude_code.rs | 77 | ||||
| -rw-r--r-- | src/detect/agents/cline.rs | 7 | ||||
| -rw-r--r-- | src/detect/agents/codex.rs | 9 | ||||
| -rw-r--r-- | src/detect/agents/droid.rs | 1 | ||||
| -rw-r--r-- | src/detect/agents/mod.rs | 8 | ||||
| -rw-r--r-- | src/detect/agents/qodercli.rs | 19 | ||||
| -rw-r--r-- | src/detect/mod.rs | 68 |
7 files changed, 100 insertions, 89 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 diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 10ecfc6b..f2def663 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -1364,6 +1364,34 @@ mod tests { } #[test] + fn claude_old_permission_prompt_above_wrapped_live_prompt_is_not_visible_blocker() { + let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ explain why this stale approval prompt should not be considered live anymore because\n my current prompt wrapped onto a second line\n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert!(!detection.visible_blocker); + assert_eq!( + crate::agent_detection_policy::apply_detection_policy( + crate::agent_detection_policy::DetectionPolicyInput { + agent: Some(Agent::Claude), + screen_detection: detection, + process_exited: false, + startup_grace_active: false, + pty_signal: Some(crate::agent_detection_policy::PtySignal { + active: false, + tainted: false, + }), + }, + ), + crate::agent_detection_policy::DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }) + ); + } + + #[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); @@ -1448,12 +1476,6 @@ mod tests { } #[test] - fn claude_idle_search() { - let screen = "⌕ Search…\nsome content"; - assert_eq!(detect_claude(screen), AgentState::Idle); - } - - #[test] fn claude_working_not_confused_by_old_prompt() { // The "esc to interrupt" is ABOVE the prompt box — should be working let screen = "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────"; @@ -1501,11 +1523,10 @@ mod tests { } #[test] - fn codex_replayed_transcript_weak_blocked_text_above_prompt_is_idle() { + fn codex_replayed_transcript_weak_blocked_text_above_prompt_is_not_visible_blocker() { 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_blocker); } @@ -1637,7 +1658,6 @@ mod tests { "• Notes mention esc to interrupt as a phrase\n\n› Fix Codex detection\n\n ~/Projects/herdr · master", ); - assert_eq!(detection.state, AgentState::Idle); assert!(!detection.visible_blocker); assert!(!detection.visible_working); } @@ -1698,13 +1718,12 @@ mod tests { } #[test] - fn codex_stale_working_status_above_current_idle_prompt_is_idle() { + fn codex_stale_working_status_above_current_prompt_is_not_visible_working() { 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_working); } @@ -1715,7 +1734,6 @@ mod tests { "› 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_working); } @@ -1748,7 +1766,6 @@ 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::Idle); assert!(!detection.visible_working); } @@ -1759,7 +1776,6 @@ 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::Idle); assert!(!detection.visible_working); } @@ -2033,11 +2049,10 @@ mod tests { } #[test] - fn cline_idle_ready() { - assert_eq!( - detect_cline("cline is ready for your message"), - AgentState::Idle - ); + fn cline_ready_text_is_not_visible_blocker() { + let detection = detect_agent(Some(Agent::Cline), "cline is ready for your message"); + + assert!(!detection.visible_blocker); } #[test] @@ -2773,20 +2788,21 @@ fix: keep working set warm across reloads\n\ } #[test] - fn qodercli_idle_override_wins_over_spinner_row() { - // While the user is holding Ctrl+C, qodercli flashes a "press again" - // banner over the prompt. The pane is effectively idle there even if - // a stale spinner row is still in the buffer. + fn qodercli_exit_hint_is_not_visible_blocker() { let screen = "\ \u{280B} Thinking...\n\ Press Ctrl+C again to exit.\n"; - assert_eq!(detect_qodercli(screen), AgentState::Idle); + let detection = detect_agent(Some(Agent::Qodercli), screen); + + assert!(!detection.visible_blocker); } #[test] - fn qodercli_idle_override_wins_over_esc_rewind() { + fn qodercli_esc_rewind_is_not_visible_blocker() { let screen = "Press Esc again to rewind.\n"; - assert_eq!(detect_qodercli(screen), AgentState::Idle); + let detection = detect_agent(Some(Agent::Qodercli), screen); + + assert!(!detection.visible_blocker); } #[test] |
