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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
use tracing::trace;
use crate::agents::AgentMode;
use crate::config::SpinnerVerbsMode;
use super::constants::{BUILTIN_SPINNER_VERBS, CONTENT_SPINNER_CHARS, TURN_DURATION_VERBS};
use super::ClaudeCodeDetector;
use super::DetectionContext;
use crate::detectors::common::safe_tail;
/// Spinner, mode, and task detection methods for ClaudeCodeDetector
impl ClaudeCodeDetector {
/// Check if content contains Tasks list with in-progress tasks
/// ◼ indicates an in-progress task in Claude Code's task list
pub(super) fn has_in_progress_tasks(content: &str) -> bool {
// Look for the Tasks header pattern and in-progress indicator
let recent = safe_tail(content, 2000);
// Check for Tasks header with in_progress count > 0
for line in recent.lines() {
let trimmed = line.trim();
// Match task summary formats:
// - "Tasks (X done, Y in progress, Z open)" (Teams/Plan format)
// - "N tasks (X done, Y in progress, Z open)" (internal task list)
// - "N task (X done, Y in progress, Z open)" (singular)
let is_task_summary = trimmed.contains("in progress")
&& (trimmed.starts_with("Tasks (")
|| trimmed.contains(" tasks (")
|| trimmed.contains(" task ("));
if is_task_summary {
// Check if there's at least 1 in progress
if let Some(start) = trimmed.find(", ") {
if let Some(end) = trimmed[start + 2..].find(" in progress") {
let num_str = &trimmed[start + 2..start + 2 + end];
if let Ok(count) = num_str.parse::<u32>() {
if count > 0 {
return true;
}
}
}
}
}
// Check for ◼ indicator (in-progress task)
// Formats: "◼ #N task name" (Teams) or "◼ task name" (internal)
if trimmed.starts_with('◼') {
return true;
}
}
false
}
/// Check if title matches custom spinner verbs from settings
///
/// Returns Some(activity) if a custom verb matches, None otherwise.
pub(super) fn detect_custom_spinner_verb(
title: &str,
context: &DetectionContext,
) -> Option<String> {
let settings_cache = context.settings_cache?;
let settings = settings_cache.get_settings(context.cwd)?;
let spinner_config = settings.spinner_verbs?;
if spinner_config.verbs.is_empty() {
return None;
}
// Check if title starts with any custom verb
for verb in &spinner_config.verbs {
if title.starts_with(verb) {
// Extract activity text after the verb
let activity = title
.strip_prefix(verb)
.map(|s| s.trim_start())
.unwrap_or("")
.to_string();
return Some(activity);
}
}
None
}
/// Detect turn duration completion pattern (e.g., "✻ Cooked for 1m 6s")
///
/// When Claude Code finishes a turn, it displays a line like "✻ Cooked for 1m 6s"
/// using a past-tense verb. This is a definitive Idle indicator.
///
/// Only checks the last 5 non-empty lines to avoid matching residual
/// turn duration messages from previous turns while a new turn is active.
pub(super) fn detect_turn_duration(content: &str) -> Option<String> {
for line in content
.lines()
.rev()
.filter(|line| !line.trim().is_empty())
.take(5)
{
let trimmed = line.trim();
// Check for content spinner char at the start (Unicode only, not plain *)
let first_char = match trimmed.chars().next() {
Some(c) => c,
None => continue,
};
if !CONTENT_SPINNER_CHARS.contains(&first_char) {
continue;
}
let rest = trimmed[first_char.len_utf8()..].trim_start();
// Check for past-tense verb + " for " + duration pattern
for verb in TURN_DURATION_VERBS {
if let Some(after_verb) = rest.strip_prefix(verb) {
if after_verb.starts_with(" for ") {
return Some(trimmed.to_string());
}
}
}
}
None
}
/// Detect active spinner verbs in content area
///
/// Claude Code shows spinner activity like "✶ Spinning…", "✻ Levitating…", "* Working…"
/// in the content. Active spinners contain "…" (ellipsis), while completed ones show
/// "✻ Crunched for 6m 5s" (past tense + time, no ellipsis).
///
/// Returns (matched_text, is_builtin_verb) — builtin verbs get High confidence,
/// unknown/custom verbs get Medium confidence.
///
/// This is critical for detecting processing when the title still shows ✳ (idle),
/// e.g. during /compact or title update lag.
pub(super) fn detect_content_spinner(
content: &str,
context: &DetectionContext,
) -> Option<(String, bool)> {
// If idle prompt ❯ is near the end (last 5 non-empty lines), any spinner above is a past residual
let has_idle_prompt = content
.lines()
.rev()
.filter(|line| !line.trim().is_empty())
.take(5)
.any(|line| {
let trimmed = line.trim();
trimmed == "❯" || trimmed == "›"
});
if has_idle_prompt {
trace!("detect_content_spinner: skipped due to idle prompt (❯/›) in last 5 non-empty lines");
return None;
}
// Check last 15 non-empty lines (skip empty lines entirely).
// Claude Code TUI has status bar (3 lines) + separators + empty padding,
// so using raw line count can miss spinners beyond the window.
for line in content
.lines()
.rev()
.filter(|line| !line.trim().is_empty())
.take(15)
{
let trimmed = line.trim();
let first_char = match trimmed.chars().next() {
Some(c) => c,
None => continue,
};
// Check for decorative asterisk chars or plain '*'
let is_spinner_char = CONTENT_SPINNER_CHARS.contains(&first_char) || first_char == '*';
if !is_spinner_char {
continue;
}
let rest = trimmed[first_char.len_utf8()..].trim_start();
// Must start with uppercase letter (verb) and contain ellipsis (active)
let starts_upper = rest
.chars()
.next()
.map(|c| c.is_uppercase())
.unwrap_or(false);
let has_ellipsis = rest.contains('…') || rest.contains("...");
if starts_upper && has_ellipsis {
// Extract the verb (first word) and check against builtin/custom lists
let verb = rest.split_whitespace().next().unwrap_or("");
// Strip trailing ellipsis from verb if present (e.g., "Spinning…")
let verb_clean = verb.trim_end_matches('…').trim_end_matches("...");
let is_builtin = BUILTIN_SPINNER_VERBS.contains(&verb_clean);
// Also check custom spinnerVerbs from Claude Code settings
let is_custom = if !is_builtin {
context
.settings_cache
.and_then(|cache| cache.get_settings(context.cwd))
.and_then(|s| s.spinner_verbs)
.map(|config| config.verbs.iter().any(|v| v == verb_clean))
.unwrap_or(false)
} else {
false
};
return Some((trimmed.to_string(), is_builtin || is_custom));
}
}
None
}
/// Detect permission mode from title icon
///
/// Claude Code displays mode icons in the terminal title:
/// - ⏸ (U+23F8) = Plan mode
/// - ⇢ (U+21E2) = Delegate mode
/// - ⏵⏵ (U+23F5 x2) = Auto-approve (acceptEdits/bypassPermissions/dontAsk)
pub fn detect_mode(title: &str) -> AgentMode {
if title.contains('\u{23F8}') {
AgentMode::Plan
} else if title.contains('\u{21E2}') {
AgentMode::Delegate
} else if title.contains("\u{23F5}\u{23F5}") {
AgentMode::AutoApprove
} else {
AgentMode::Default
}
}
/// Check if we should skip default Braille spinner detection
///
/// Returns true if mode is "replace" and custom verbs are configured.
pub(super) fn should_skip_default_spinners(context: &DetectionContext) -> bool {
let settings_cache = match context.settings_cache {
Some(cache) => cache,
None => return false,
};
let settings = match settings_cache.get_settings(context.cwd) {
Some(s) => s,
None => return false,
};
matches!(
settings.spinner_verbs,
Some(ref config) if config.mode == SpinnerVerbsMode::Replace && !config.verbs.is_empty()
)
}
}