blob: 819dadf45085ad5c9a06c12fe2749e5f9e39616a (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
|
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;
}
if has_live_blocked_form(content) {
return AgentState::Blocked;
}
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();
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 {
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_background_agent_wait(above)
|| 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_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
.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 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;
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 == '─')
}
|