aboutsummaryrefslogtreecommitdiff
path: root/src/detect
diff options
context:
space:
mode:
Diffstat (limited to 'src/detect')
-rw-r--r--src/detect/agents/claude_code.rs64
-rw-r--r--src/detect/agents/opencode.rs31
-rw-r--r--src/detect/mod.rs102
3 files changed, 181 insertions, 16 deletions
diff --git a/src/detect/agents/claude_code.rs b/src/detect/agents/claude_code.rs
index e6633eda..819dadf4 100644
--- a/src/detect/agents/claude_code.rs
+++ b/src/detect/agents/claude_code.rs
@@ -23,30 +23,35 @@ pub(super) fn detect(content: &str) -> AgentState {
return AgentState::Idle;
}
- // --- Blocked detection (full content including prompt box) ---
-
- if has_claude_blocked_prompt(content, &lower) {
+ if has_live_blocked_form(content) {
return AgentState::Blocked;
}
- // --- Working detection (content above the prompt box) ---
-
if has_working_chrome(content) {
return AgentState::Working;
}
+ if !has_prompt_box(content) && has_claude_blocked_prompt(content, &lower) {
+ return AgentState::Blocked;
+ }
+
+ if has_prompt_box(content) {
+ return AgentState::Idle;
+ }
+
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"))
+ has_live_blocked_form(content)
+ || 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 {
@@ -54,6 +59,7 @@ pub(super) fn has_working_chrome(content: &str) -> bool {
let above_lower = above.to_lowercase();
above_lower.contains("esc to interrupt")
|| above_lower.contains("ctrl+c to interrupt")
+ || has_background_agent_wait(above)
|| has_spinner_activity(above)
}
@@ -86,6 +92,26 @@ fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool {
|| (has_selection_prompt(content) && has_claude_yes_no_choice(content))
}
+fn has_live_blocked_form(content: &str) -> bool {
+ let region = content_after_last_horizontal_rule(content);
+ region.lines().any(|line| {
+ let lower = line.to_lowercase();
+ lower.contains("enter to select")
+ && lower.contains("esc to cancel")
+ && (lower.contains("tab/arrow keys to navigate")
+ || lower.contains("arrow keys to navigate"))
+ })
+}
+
+fn has_background_agent_wait(content_above_prompt: &str) -> bool {
+ content_above_prompt.lines().rev().take(8).any(|line| {
+ let lower = line.to_lowercase();
+ lower.contains("waiting for")
+ && lower.contains("background agent")
+ && lower.contains("to finish")
+ })
+}
+
fn has_claude_yes_no_choice(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line
@@ -140,6 +166,20 @@ pub(in crate::detect) fn content_above_prompt_box(content: &str) -> &str {
content
}
+fn content_after_last_horizontal_rule(content: &str) -> &str {
+ let mut last_rule_end = 0usize;
+ let mut offset = 0usize;
+ for line in content.lines() {
+ let next_offset = offset + line.len() + 1;
+ if is_horizontal_rule(line) {
+ last_rule_end = next_offset.min(content.len());
+ }
+ offset = next_offset;
+ }
+
+ &content[last_rule_end..]
+}
+
fn claude_prompt_box_top_border_index(lines: &[&str]) -> Option<usize> {
let mut border_count = 0;
diff --git a/src/detect/agents/opencode.rs b/src/detect/agents/opencode.rs
index 5badcad9..219f69ed 100644
--- a/src/detect/agents/opencode.rs
+++ b/src/detect/agents/opencode.rs
@@ -7,7 +7,10 @@ pub(super) fn detect(content: &str) -> AgentState {
}
// Working
- if has_interrupt_pattern(&content.to_lowercase()) {
+ if has_interrupt_pattern(&content.to_lowercase())
+ || has_opencode_interrupt_footer(content)
+ || has_opencode_progress_run(content)
+ {
return AgentState::Working;
}
@@ -23,3 +26,29 @@ fn has_opencode_question_prompt(content: &str) -> bool {
lower.contains("esc dismiss") && has_enter_action && has_question_nav
}
+
+fn has_opencode_interrupt_footer(content: &str) -> bool {
+ content.lines().any(|line| {
+ let lower = line.to_lowercase();
+ if !(lower.contains("esc interrupt") || lower.contains("esc again to interrupt")) {
+ return false;
+ }
+
+ lower.contains("opencode")
+ })
+}
+
+fn has_opencode_progress_run(line: &str) -> bool {
+ let mut run = 0usize;
+ for ch in line.chars() {
+ if matches!(ch, '■' | '⬝') {
+ run += 1;
+ if run >= 4 {
+ return true;
+ }
+ } else {
+ run = 0;
+ }
+ }
+ false
+}
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index e5f84628..ff27892b 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -268,11 +268,11 @@ fn detect_kilo(content: &str) -> AgentState {
// Shared helpers
// ---------------------------------------------------------------------------
-/// Check for "do you want"/"would you like" followed by "yes" or "❯"
+/// Check for action confirmations followed by "yes" or "❯".
fn has_confirmation_prompt(lower_content: &str) -> bool {
if let Some(pos) = lower_content
- .find("do you want")
- .or_else(|| lower_content.find("would you like"))
+ .find("do you want to")
+ .or_else(|| lower_content.find("would you like to"))
{
let after = &lower_content[pos..];
return after.contains("yes") || after.contains('❯');
@@ -990,6 +990,26 @@ mod tests {
}
#[test]
+ fn claude_question_form_selected_top_is_visible_blocker() {
+ let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n❯ 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn claude_question_form_selected_bottom_is_visible_blocker() {
+ let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · ctrl+g to edit in Zed · Esc to cancel";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
fn claude_idle_hooks_menu() {
let screen = "Hooks\n0 hooks configured\nℹ This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude. Learn more\n\n❯ 1. PreToolUse\n 2. PostToolUse\n 3. PostToolUseFailure\n\nEnter to confirm · Esc to cancel";
assert_eq!(detect_claude(screen), AgentState::Idle);
@@ -1028,6 +1048,28 @@ mod tests {
}
#[test]
+ fn claude_declined_questions_in_scrollback_with_prompt_box_are_idle() {
+ let screen = "● User declined to answer questions\n ⎿ · What do you want help with? (Code task / PR review / Research / Claude setup)\n · How detailed should I be? (Short / Medium / Detailed)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~ ⊘ no git ▱▱▱▱▱ 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_old_permission_prompt_with_live_prompt_box_is_idle() {
+ 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❯ \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);
@@ -1058,6 +1100,17 @@ mod tests {
}
#[test]
+ fn claude_waiting_for_background_agent_is_working() {
+ let screen = "● Done. I’ve delegated a read-only repo investigation to a subagent, and it will come back with a detailed report.\n\n✻ Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/llm-proxy ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ Explore Investigate repo and report 33s · ↓ 225 tokens";
+ 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_idle_search() {
let screen = "⌕ Search…\nsome content";
assert_eq!(detect_claude(screen), AgentState::Idle);
@@ -1573,6 +1626,49 @@ mod tests {
}
#[test]
+ fn opencode_working_on_footer_interrupt() {
+ let screen = "\
+ ▣ Build · MiniMax M3 Free\n\
+\n\
+ ┃\n\
+ ┃ Build · MiniMax M3 Free OpenCode Zen ~/Projects/llm-proxy:master\n\
+ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n\
+ ⬝⬝⬝■■■■■ esc interrupt 24.4K (12%) ctrl+p commands • OpenCode 1.15.13";
+ assert_eq!(detect_opencode(screen), AgentState::Working);
+ }
+
+ #[test]
+ fn opencode_working_on_progress_footer_without_product_text() {
+ assert_eq!(
+ detect_opencode("■■■■■■⬝⬝ esc interrupt"),
+ AgentState::Working
+ );
+ }
+
+ #[test]
+ fn opencode_working_on_escape_again_footer() {
+ assert_eq!(
+ detect_opencode(
+ "⬝⬝■■■■■■ esc again to interrupt 14.3K (7%) ctrl+p commands • OpenCode 1.15.13"
+ ),
+ AgentState::Working
+ );
+ }
+
+ #[test]
+ fn opencode_progress_row_alone_is_working() {
+ assert_eq!(detect_opencode("■■■■■■⬝⬝"), AgentState::Working);
+ }
+
+ #[test]
+ fn opencode_ctrl_p_commands_alone_is_not_working() {
+ assert_eq!(
+ detect_opencode("esc interrupt 24.4K (12%) ctrl+p commands"),
+ AgentState::Idle
+ );
+ }
+
+ #[test]
fn opencode_waiting_question_prompt() {
assert_eq!(
detect_opencode(