1use regex::Regex;
2use tracing::trace;
3
4use crate::agents::{AgentMode, AgentStatus, AgentType, ApprovalType};
5use crate::config::SpinnerVerbsMode;
6
7use super::{DetectionConfidence, DetectionContext, DetectionResult, StatusDetector};
8
9const IDLE_INDICATOR: char = '✳';
11
12const PROCESSING_SPINNERS: &[char] = &[
18 '⠂', '⠐', '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '⠿', '⠾', '⠽', '⠻', '⠟', '⠯', '⠷', '⠳', '⠱',
21 '⠰', '◐', '◓', '◑', '◒',
23];
24
25const TURN_DURATION_VERBS: &[&str] = &[
28 "Baked",
29 "Brewed",
30 "Churned",
31 "Cogitated",
32 "Cooked",
33 "Crunched",
34 "Sautéed",
35 "Worked",
36];
37
38const CONTENT_SPINNER_CHARS: &[char] = &['✶', '✻', '✽', '✹', '✧', '✢', '·', '✳'];
46
47const BUILTIN_SPINNER_VERBS: &[&str] = &[
53 "Accomplishing",
54 "Actioning",
55 "Actualizing",
56 "Architecting",
57 "Baking",
58 "Beaming",
59 "Beboppin'",
60 "Befuddling",
61 "Billowing",
62 "Blanching",
63 "Bloviating",
64 "Boogieing",
65 "Boondoggling",
66 "Booping",
67 "Bootstrapping",
68 "Brewing",
69 "Burrowing",
70 "Calculating",
71 "Canoodling",
72 "Caramelizing",
73 "Cascading",
74 "Catapulting",
75 "Cerebrating",
76 "Channeling",
77 "Channelling",
78 "Choreographing",
79 "Churning",
80 "Clauding",
81 "Coalescing",
82 "Cogitating",
83 "Combobulating",
84 "Composing",
85 "Computing",
86 "Concocting",
87 "Considering",
88 "Contemplating",
89 "Cooking",
90 "Crafting",
91 "Creating",
92 "Crunching",
93 "Crystallizing",
94 "Cultivating",
95 "Deciphering",
96 "Deliberating",
97 "Determining",
98 "Dilly-dallying",
99 "Discombobulating",
100 "Doing",
101 "Doodling",
102 "Drizzling",
103 "Ebbing",
104 "Effecting",
105 "Elucidating",
106 "Embellishing",
107 "Enchanting",
108 "Envisioning",
109 "Evaporating",
110 "Fermenting",
111 "Fiddle-faddling",
112 "Finagling",
113 "Flambéing",
114 "Flibbertigibbeting",
115 "Flowing",
116 "Flummoxing",
117 "Fluttering",
118 "Forging",
119 "Forming",
120 "Frolicking",
121 "Frosting",
122 "Gallivanting",
123 "Galloping",
124 "Garnishing",
125 "Generating",
126 "Germinating",
127 "Gitifying",
128 "Grooving",
129 "Gusting",
130 "Harmonizing",
131 "Hashing",
132 "Hatching",
133 "Herding",
134 "Honking",
135 "Hullaballooing",
136 "Hyperspacing",
137 "Ideating",
138 "Imagining",
139 "Improvising",
140 "Incubating",
141 "Inferring",
142 "Infusing",
143 "Ionizing",
144 "Jitterbugging",
145 "Julienning",
146 "Kneading",
147 "Leavening",
148 "Levitating",
149 "Lollygagging",
150 "Manifesting",
151 "Marinating",
152 "Meandering",
153 "Metamorphosing",
154 "Misting",
155 "Moonwalking",
156 "Moseying",
157 "Mulling",
158 "Mustering",
159 "Musing",
160 "Nebulizing",
161 "Nesting",
162 "Newspapering",
163 "Noodling",
164 "Nucleating",
165 "Orbiting",
166 "Orchestrating",
167 "Osmosing",
168 "Perambulating",
169 "Percolating",
170 "Perusing",
171 "Philosophising",
172 "Photosynthesizing",
173 "Pollinating",
174 "Pondering",
175 "Pontificating",
176 "Pouncing",
177 "Precipitating",
178 "Prestidigitating",
179 "Processing",
180 "Proofing",
181 "Propagating",
182 "Puttering",
183 "Puzzling",
184 "Quantumizing",
185 "Razzle-dazzling",
186 "Razzmatazzing",
187 "Recombobulating",
188 "Reticulating",
189 "Roosting",
190 "Ruminating",
191 "Sautéing",
192 "Scampering",
193 "Schlepping",
194 "Scurrying",
195 "Seasoning",
196 "Shenaniganing",
197 "Shimmying",
198 "Simmering",
199 "Skedaddling",
200 "Sketching",
201 "Slithering",
202 "Smooshing",
203 "Sock-hopping",
204 "Spelunking",
205 "Spinning",
206 "Sprouting",
207 "Stewing",
208 "Sublimating",
209 "Swirling",
210 "Swooping",
211 "Symbioting",
212 "Synthesizing",
213 "Tempering",
214 "Thinking",
215 "Thundering",
216 "Tinkering",
217 "Tomfoolering",
218 "Topsy-turvying",
219 "Transfiguring",
220 "Transmuting",
221 "Twisting",
222 "Undulating",
223 "Unfurling",
224 "Unravelling",
225 "Vibing",
226 "Waddling",
227 "Wandering",
228 "Warping",
229 "Whatchamacalliting",
230 "Whirlpooling",
231 "Whirring",
232 "Whisking",
233 "Wibbling",
234 "Working",
235 "Wrangling",
236 "Zesting",
237 "Zigzagging",
238];
239
240pub struct ClaudeCodeDetector {
242 file_edit_pattern: Regex,
244 file_create_pattern: Regex,
245 file_delete_pattern: Regex,
246 bash_pattern: Regex,
247 mcp_pattern: Regex,
248 general_approval_pattern: Regex,
249 choice_pattern: Regex,
251 error_pattern: Regex,
253}
254
255fn strip_box_drawing(text: &str) -> &str {
258 if let Some(pos) = text.find(|c: char| ('\u{2500}'..='\u{257F}').contains(&c)) {
259 text[..pos].trim()
260 } else {
261 text
262 }
263}
264
265impl ClaudeCodeDetector {
266 pub fn new() -> Self {
267 Self {
268 file_edit_pattern: Regex::new(
269 r"(?i)(Edit|Write|Modify)\s+.*?\?|Do you want to (edit|write|modify)|Allow.*?edit",
270 )
271 .expect("Invalid file_edit_pattern regex"),
272 file_create_pattern: Regex::new(
273 r"(?i)Create\s+.*?\?|Do you want to create|Allow.*?create",
274 )
275 .expect("Invalid file_create_pattern regex"),
276 file_delete_pattern: Regex::new(
277 r"(?i)Delete\s+.*?\?|Do you want to delete|Allow.*?delete",
278 )
279 .expect("Invalid file_delete_pattern regex"),
280 bash_pattern: Regex::new(
281 r"(?i)(Run|Execute)\s+(command|bash|shell)|Do you want to run|Allow.*?(command|bash)|run this command",
282 )
283 .expect("Invalid bash_pattern regex"),
284 mcp_pattern: Regex::new(r"(?i)MCP\s+tool|Do you want to use.*?MCP|Allow.*?MCP")
285 .expect("Invalid mcp_pattern regex"),
286 general_approval_pattern: Regex::new(
287 r"(?i)\[y/n\]|\[Y/n\]|\[yes/no\]|\(Y\)es\s*/\s*\(N\)o|Yes\s*/\s*No|y/n|Allow\?|Do you want to (allow|proceed|continue|run|execute)",
288 )
289 .expect("Invalid general_approval_pattern regex"),
290 choice_pattern: Regex::new(r"^\s*(?:[>❯›]\s*)?(\d+)\.\s+(.+)$")
292 .expect("Invalid choice_pattern regex"),
293 error_pattern: Regex::new(r"(?i)(?:^|\n)\s*(?:Error|ERROR|error:|✗|❌)")
294 .expect("Invalid error_pattern regex"),
295 }
296 }
297
298 fn is_horizontal_separator(line: &str) -> bool {
301 let trimmed = line.trim();
302 trimmed.len() >= 10 && trimmed.chars().all(|c| c == '─')
304 }
305
306 fn detect_user_question(&self, content: &str) -> Option<(ApprovalType, String)> {
308 let lines: Vec<&str> = content.lines().collect();
309 if lines.is_empty() {
310 return None;
311 }
312
313 let effective_len = lines
315 .iter()
316 .rposition(|line| !line.trim().is_empty())
317 .map(|i| i + 1)
318 .unwrap_or(lines.len());
319 let lines = &lines[..effective_len];
320
321 let separator_indices: Vec<usize> = lines
326 .iter()
327 .enumerate()
328 .rev()
329 .filter(|(_, line)| Self::is_horizontal_separator(line))
330 .map(|(i, _)| i)
331 .take(2)
332 .collect();
333
334 let check_lines = if separator_indices.len() == 2 {
335 let lower_sep = separator_indices[0];
337 let upper_sep = separator_indices[1];
338 if lower_sep > upper_sep + 1 {
339 &lines[upper_sep + 1..lower_sep]
340 } else {
341 &lines[lines.len().saturating_sub(25)..lines.len()]
342 }
343 } else {
344 let last_prompt_idx = lines.iter().rposition(|line| {
347 let trimmed = line.trim();
348 if trimmed == "❯" || trimmed == "›" {
349 return true;
350 }
351 if trimmed.starts_with('❯') || trimmed.starts_with('›') {
352 let after_marker = trimmed
353 .trim_start_matches('❯')
354 .trim_start_matches('›')
355 .trim_start();
356 if after_marker
357 .chars()
358 .next()
359 .map(|c| c.is_ascii_digit())
360 .unwrap_or(false)
361 {
362 return false;
363 }
364 return trimmed.len() < 3;
365 }
366 false
367 });
368 let search_end = last_prompt_idx.unwrap_or(lines.len());
369 let search_start = if search_end == lines.len() {
370 lines.len().saturating_sub(25)
371 } else {
372 search_end.saturating_sub(25)
373 };
374 &lines[search_start..search_end]
375 };
376
377 if check_lines.is_empty() {
378 return None;
379 }
380
381 let mut choices = Vec::new();
382 let mut question = String::new();
383 let mut first_choice_idx = None;
384 let mut last_choice_idx = None;
385 let mut is_multi_select = false;
386 let mut cursor_position: usize = 0;
387
388 for line in check_lines.iter() {
390 let lower = line.to_lowercase();
391 if lower.contains("space to")
392 || lower.contains("toggle")
393 || lower.contains("select all")
394 || lower.contains("multi")
395 {
396 is_multi_select = true;
397 break;
398 }
399 }
400
401 if !is_multi_select {
403 for line in check_lines.iter() {
404 if let Some(cap) = self.choice_pattern.captures(line) {
405 let choice_text = cap[2].trim();
406 if choice_text.starts_with("[ ]")
407 || choice_text.starts_with("[x]")
408 || choice_text.starts_with("[X]")
409 || choice_text.starts_with("[×]")
410 || choice_text.starts_with("[✔]")
411 {
412 is_multi_select = true;
413 break;
414 }
415 }
416 }
417 }
418
419 if !is_multi_select {
420 for line in check_lines.iter() {
421 let lower = line.to_lowercase();
422 if lower.contains("複数選択") {
425 is_multi_select = true;
426 break;
427 }
428 }
429 }
430
431 let mut best_choices: Vec<String> = Vec::new();
433 let mut best_first_idx: Option<usize> = None;
434 let mut best_last_idx: Option<usize> = None;
435 let mut best_cursor_position: usize = 0;
436
437 for (i, line) in check_lines.iter().enumerate() {
438 let trimmed = line.trim();
439
440 if trimmed.starts_with('│')
442 || trimmed.starts_with('├')
443 || trimmed.starts_with('└')
444 || trimmed.starts_with('┌')
445 || trimmed.starts_with('─')
446 || trimmed.starts_with('✻')
447 || trimmed.starts_with('╌')
448 {
449 continue;
450 }
451
452 if let Some(cap) = self.choice_pattern.captures(line) {
454 if let Ok(num) = cap[1].parse::<u32>() {
455 let choice_text = strip_box_drawing(cap[2].trim());
457 if num as usize == choices.len() + 1 {
458 let label = choice_text
459 .split('(')
460 .next()
461 .unwrap_or(choice_text)
462 .trim()
463 .to_string();
464 choices.push(label);
465 if first_choice_idx.is_none() {
466 first_choice_idx = Some(i);
467 }
468 last_choice_idx = Some(i);
469
470 if trimmed.starts_with('❯')
472 || trimmed.starts_with('›')
473 || trimmed.starts_with('>')
474 {
475 cursor_position = num as usize;
476 }
477 } else if num == 1 {
478 if choices.len() >= 2 && cursor_position > 0 {
480 best_choices = choices.clone();
481 best_first_idx = first_choice_idx;
482 best_last_idx = last_choice_idx;
483 best_cursor_position = cursor_position;
484 }
485 choices.clear();
487 let label = choice_text
488 .split('(')
489 .next()
490 .unwrap_or(choice_text)
491 .trim()
492 .to_string();
493 choices.push(label);
494 first_choice_idx = Some(i);
495 last_choice_idx = Some(i);
496 cursor_position = if trimmed.starts_with('❯')
497 || trimmed.starts_with('›')
498 || trimmed.starts_with('>')
499 {
500 1
501 } else {
502 0
503 };
504 }
505 }
506 }
507 }
508
509 if choices.len() >= 2 && cursor_position > 0 {
511 best_choices = choices;
512 best_first_idx = first_choice_idx;
513 best_last_idx = last_choice_idx;
514 best_cursor_position = cursor_position;
515 }
516
517 choices = best_choices;
519 first_choice_idx = best_first_idx;
520 last_choice_idx = best_last_idx;
521 cursor_position = best_cursor_position;
522
523 let used_separators = separator_indices.len() == 2;
527 let max_distance: usize = if used_separators {
528 check_lines.len()
531 } else {
532 20
534 };
535 if let Some(last_idx) = last_choice_idx {
536 let effective_end = check_lines
537 .iter()
538 .rposition(|line| !line.trim().is_empty())
539 .map(|i| i + 1)
540 .unwrap_or(check_lines.len());
541 if effective_end - last_idx > max_distance {
542 return None;
543 }
544 }
545
546 if let Some(first_idx) = first_choice_idx {
548 for j in (0..first_idx).rev() {
549 let prev = check_lines[j].trim();
550 if prev.is_empty() {
551 continue;
552 }
553 if prev.ends_with('?') || prev.ends_with('?') {
554 question = prev.to_string();
555 break;
556 }
557 if question.is_empty() {
558 question = prev.to_string();
559 }
560 if first_idx - j > 5 {
561 break;
562 }
563 }
564 }
565
566 if choices.len() >= 2 {
567 let tail_lines: Vec<&str> = lines
570 .iter()
571 .rev()
572 .filter(|l| !l.trim().is_empty())
573 .take(8)
574 .copied()
575 .collect();
576 let tail_text = tail_lines.join(" ");
577 if tail_text.contains("Enter to confirm") {
578 return None;
579 }
580
581 let cursor = if cursor_position == 0 {
583 1
584 } else {
585 cursor_position
586 };
587 Some((
588 ApprovalType::UserQuestion {
589 choices,
590 multi_select: is_multi_select,
591 cursor_position: cursor,
592 },
593 question,
594 ))
595 } else {
596 None
597 }
598 }
599
600 fn detect_proceed_prompt(content: &str) -> Option<Vec<String>> {
605 let check_lines: Vec<&str> = content
607 .lines()
608 .filter(|line| !line.trim().is_empty())
609 .collect::<Vec<_>>()
610 .into_iter()
611 .rev()
612 .take(15)
613 .collect::<Vec<_>>()
614 .into_iter()
615 .rev()
616 .collect();
617
618 let mut has_yes = false;
619 let mut has_no = false;
620
621 for line in &check_lines {
622 let trimmed = line.trim();
623 if trimmed.contains("1.") && trimmed.contains("Yes") {
625 has_yes = true;
626 }
627 if (trimmed.contains("2. No") || trimmed.contains("3. No")) && trimmed.len() < 20 {
629 has_no = true;
630 }
631 }
632
633 if !(has_yes && has_no) {
634 return None;
635 }
636
637 let mut choices = Vec::new();
639 for line in &check_lines {
640 let clean = line
641 .trim()
642 .trim_start_matches('❯')
643 .trim_start_matches('›')
644 .trim_start_matches('>')
645 .trim();
646 if let Some(dot_pos) = clean.find(". ") {
647 if let Ok(num) = clean[..dot_pos].trim().parse::<usize>() {
648 if num == choices.len() + 1 {
649 let choice_text = strip_box_drawing(clean[dot_pos + 2..].trim());
651 let label = choice_text
652 .split('(')
653 .next()
654 .unwrap_or(choice_text)
655 .trim()
656 .to_string();
657 choices.push(label);
658 }
659 }
660 }
661 }
662
663 if choices.len() >= 2 {
664 Some(choices)
665 } else {
666 None
667 }
668 }
669
670 fn extract_question_text(content: &str) -> String {
672 content
673 .lines()
674 .rev()
675 .take(20)
676 .find(|line| {
677 let t = line.trim();
678 !t.is_empty() && (t.ends_with('?') || t.ends_with('?'))
679 })
680 .map(|l| l.trim().to_string())
681 .unwrap_or_else(|| "Do you want to proceed?".to_string())
682 }
683
684 fn detect_yes_no_buttons(&self, lines: &[&str]) -> bool {
686 let check_lines: Vec<&str> = lines.iter().rev().take(8).copied().collect();
687
688 let mut has_yes = false;
689 let mut has_no = false;
690 let mut yes_line_idx: Option<usize> = None;
691 let mut no_line_idx: Option<usize> = None;
692
693 for (idx, line) in check_lines.iter().enumerate() {
694 let trimmed = line.trim();
695
696 if trimmed.is_empty() || trimmed.len() > 50 {
697 continue;
698 }
699
700 if (trimmed == "Yes" || trimmed.starts_with("Yes,") || trimmed.starts_with("Yes "))
702 && trimmed.len() < 40
703 {
704 has_yes = true;
705 yes_line_idx = Some(idx);
706 }
707
708 if (trimmed == "No" || trimmed.starts_with("No,") || trimmed.starts_with("No "))
710 && trimmed.len() < 40
711 {
712 has_no = true;
713 no_line_idx = Some(idx);
714 }
715 }
716
717 if has_yes && has_no {
719 if let (Some(y_idx), Some(n_idx)) = (yes_line_idx, no_line_idx) {
720 let distance = y_idx.abs_diff(n_idx);
721 return distance <= 4;
722 }
723 }
724
725 false
726 }
727
728 fn detect_approval(&self, content: &str) -> Option<(ApprovalType, String, &'static str)> {
730 let lines: Vec<&str> = content.lines().collect();
731 if lines.is_empty() {
732 return None;
733 }
734
735 let effective_len = lines
737 .iter()
738 .rposition(|line| !line.trim().is_empty())
739 .map(|i| i + 1)
740 .unwrap_or(lines.len());
741 let lines = &lines[..effective_len];
742
743 let check_start = lines.len().saturating_sub(12);
745 let recent_lines = &lines[check_start..];
746 let _recent = recent_lines.join("\n");
747
748 if let Some((approval_type, details)) = self.detect_user_question(content) {
750 return Some((approval_type, details, "user_question_numbered_choices"));
751 }
752
753 let proceed_choices = Self::detect_proceed_prompt(content);
755 let has_proceed_prompt = proceed_choices.is_some();
756
757 let has_yes_no_buttons = self.detect_yes_no_buttons(recent_lines);
759
760 let last_lines: Vec<&str> = recent_lines.iter().rev().take(10).copied().collect();
762 let last_text = last_lines.join("\n");
763 let has_text_approval = self.general_approval_pattern.is_match(&last_text);
764
765 if !has_proceed_prompt && !has_yes_no_buttons && !has_text_approval {
766 trace!(
767 "detect_approval: no approval pattern found (user_question=None, proceed={}, buttons={}, text={})",
768 has_proceed_prompt, has_yes_no_buttons, has_text_approval
769 );
770 return None;
771 }
772
773 if let Some(choices) = proceed_choices {
775 let question = Self::extract_question_text(content);
776 return Some((
777 ApprovalType::UserQuestion {
778 choices,
779 multi_select: false,
780 cursor_position: 1,
781 },
782 question,
783 "proceed_prompt",
784 ));
785 }
786
787 let rule = if has_yes_no_buttons {
789 "yes_no_buttons"
790 } else {
791 "yes_no_text_pattern"
792 };
793
794 let context = safe_tail(content, 1500);
796
797 if self.file_edit_pattern.is_match(context) {
798 let details = self.extract_file_path(context).unwrap_or_default();
799 return Some((ApprovalType::FileEdit, details, rule));
800 }
801
802 if self.file_create_pattern.is_match(context) {
803 let details = self.extract_file_path(context).unwrap_or_default();
804 return Some((ApprovalType::FileCreate, details, rule));
805 }
806
807 if self.file_delete_pattern.is_match(context) {
808 let details = self.extract_file_path(context).unwrap_or_default();
809 return Some((ApprovalType::FileDelete, details, rule));
810 }
811
812 if self.bash_pattern.is_match(context) {
813 let details = self.extract_command(context).unwrap_or_default();
814 return Some((ApprovalType::ShellCommand, details, rule));
815 }
816
817 if self.mcp_pattern.is_match(context) {
818 return Some((ApprovalType::McpTool, "MCP tool call".to_string(), rule));
819 }
820
821 Some((
822 ApprovalType::Other("Pending approval".to_string()),
823 String::new(),
824 rule,
825 ))
826 }
827
828 fn detect_error(&self, content: &str) -> Option<String> {
830 let recent = safe_tail(content, 500);
831 if self.error_pattern.is_match(recent) {
832 for line in recent.lines().rev() {
834 let trimmed = line.trim();
835 if trimmed.to_lowercase().contains("error")
836 || trimmed.contains('✗')
837 || trimmed.contains('❌')
838 {
839 return Some(trimmed.to_string());
840 }
841 }
842 return Some("Error detected".to_string());
843 }
844 None
845 }
846
847 fn extract_file_path(&self, content: &str) -> Option<String> {
848 let path_pattern =
849 Regex::new(r"(?m)(?:file|path)[:\s]+([^\s\n]+)|([./][\w/.-]+\.\w+)").ok()?;
850 path_pattern
851 .captures(content)
852 .and_then(|c| c.get(1).or(c.get(2)))
853 .map(|m| m.as_str().to_string())
854 }
855
856 fn extract_command(&self, content: &str) -> Option<String> {
857 let cmd_pattern =
858 Regex::new(r"(?m)(?:command|run)[:\s]+`([^`]+)`|```(?:bash|sh)?\n([^`]+)```").ok()?;
859 cmd_pattern
860 .captures(content)
861 .and_then(|c| c.get(1).or(c.get(2)))
862 .map(|m| m.as_str().trim().to_string())
863 }
864
865 fn has_in_progress_tasks(content: &str) -> bool {
868 let recent = safe_tail(content, 2000);
870
871 for line in recent.lines() {
873 let trimmed = line.trim();
874 let is_task_summary = trimmed.contains("in progress")
879 && (trimmed.starts_with("Tasks (")
880 || trimmed.contains(" tasks (")
881 || trimmed.contains(" task ("));
882 if is_task_summary {
883 if let Some(start) = trimmed.find(", ") {
885 if let Some(end) = trimmed[start + 2..].find(" in progress") {
886 let num_str = &trimmed[start + 2..start + 2 + end];
887 if let Ok(count) = num_str.parse::<u32>() {
888 if count > 0 {
889 return true;
890 }
891 }
892 }
893 }
894 }
895 if trimmed.starts_with('◼') {
898 return true;
899 }
900 }
901 false
902 }
903
904 fn detect_custom_spinner_verb(title: &str, context: &DetectionContext) -> Option<String> {
908 let settings_cache = context.settings_cache?;
909 let settings = settings_cache.get_settings(context.cwd)?;
910 let spinner_config = settings.spinner_verbs?;
911
912 if spinner_config.verbs.is_empty() {
913 return None;
914 }
915
916 for verb in &spinner_config.verbs {
918 if title.starts_with(verb) {
919 let activity = title
921 .strip_prefix(verb)
922 .map(|s| s.trim_start())
923 .unwrap_or("")
924 .to_string();
925 return Some(activity);
926 }
927 }
928
929 None
930 }
931
932 fn detect_turn_duration(content: &str) -> Option<String> {
940 for line in content
941 .lines()
942 .rev()
943 .filter(|line| !line.trim().is_empty())
944 .take(5)
945 {
946 let trimmed = line.trim();
947
948 let first_char = match trimmed.chars().next() {
950 Some(c) => c,
951 None => continue,
952 };
953
954 if !CONTENT_SPINNER_CHARS.contains(&first_char) {
955 continue;
956 }
957
958 let rest = trimmed[first_char.len_utf8()..].trim_start();
959
960 for verb in TURN_DURATION_VERBS {
962 if let Some(after_verb) = rest.strip_prefix(verb) {
963 if after_verb.starts_with(" for ") {
964 return Some(trimmed.to_string());
965 }
966 }
967 }
968 }
969 None
970 }
971
972 fn detect_content_spinner(content: &str, context: &DetectionContext) -> Option<(String, bool)> {
984 let has_idle_prompt = content
986 .lines()
987 .rev()
988 .filter(|line| !line.trim().is_empty())
989 .take(5)
990 .any(|line| {
991 let trimmed = line.trim();
992 trimmed == "❯" || trimmed == "›"
993 });
994 if has_idle_prompt {
995 trace!("detect_content_spinner: skipped due to idle prompt (❯/›) in last 5 non-empty lines");
996 return None;
997 }
998
999 for line in content
1003 .lines()
1004 .rev()
1005 .filter(|line| !line.trim().is_empty())
1006 .take(15)
1007 {
1008 let trimmed = line.trim();
1009
1010 let first_char = match trimmed.chars().next() {
1011 Some(c) => c,
1012 None => continue,
1013 };
1014
1015 let is_spinner_char = CONTENT_SPINNER_CHARS.contains(&first_char) || first_char == '*';
1017 if !is_spinner_char {
1018 continue;
1019 }
1020
1021 let rest = trimmed[first_char.len_utf8()..].trim_start();
1022
1023 let starts_upper = rest
1025 .chars()
1026 .next()
1027 .map(|c| c.is_uppercase())
1028 .unwrap_or(false);
1029 let has_ellipsis = rest.contains('…') || rest.contains("...");
1030
1031 if starts_upper && has_ellipsis {
1032 let verb = rest.split_whitespace().next().unwrap_or("");
1034 let verb_clean = verb.trim_end_matches('…').trim_end_matches("...");
1036 let is_builtin = BUILTIN_SPINNER_VERBS.contains(&verb_clean);
1037 let is_custom = if !is_builtin {
1039 context
1040 .settings_cache
1041 .and_then(|cache| cache.get_settings(context.cwd))
1042 .and_then(|s| s.spinner_verbs)
1043 .map(|config| config.verbs.iter().any(|v| v == verb_clean))
1044 .unwrap_or(false)
1045 } else {
1046 false
1047 };
1048 return Some((trimmed.to_string(), is_builtin || is_custom));
1049 }
1050 }
1051 None
1052 }
1053
1054 pub fn detect_mode(title: &str) -> AgentMode {
1061 if title.contains('\u{23F8}') {
1062 AgentMode::Plan
1063 } else if title.contains('\u{21E2}') {
1064 AgentMode::Delegate
1065 } else if title.contains("\u{23F5}\u{23F5}") {
1066 AgentMode::AutoApprove
1067 } else {
1068 AgentMode::Default
1069 }
1070 }
1071
1072 fn should_skip_default_spinners(context: &DetectionContext) -> bool {
1076 let settings_cache = match context.settings_cache {
1077 Some(cache) => cache,
1078 None => return false,
1079 };
1080
1081 let settings = match settings_cache.get_settings(context.cwd) {
1082 Some(s) => s,
1083 None => return false,
1084 };
1085
1086 matches!(
1087 settings.spinner_verbs,
1088 Some(ref config) if config.mode == SpinnerVerbsMode::Replace && !config.verbs.is_empty()
1089 )
1090 }
1091}
1092
1093impl Default for ClaudeCodeDetector {
1094 fn default() -> Self {
1095 Self::new()
1096 }
1097}
1098
1099impl StatusDetector for ClaudeCodeDetector {
1100 fn detect_status(&self, title: &str, content: &str) -> AgentStatus {
1101 self.detect_status_with_reason(title, content, &DetectionContext::default())
1102 .status
1103 }
1104
1105 fn detect_status_with_context(
1106 &self,
1107 title: &str,
1108 content: &str,
1109 context: &DetectionContext,
1110 ) -> AgentStatus {
1111 self.detect_status_with_reason(title, content, context)
1112 .status
1113 }
1114
1115 fn detect_status_with_reason(
1116 &self,
1117 title: &str,
1118 content: &str,
1119 context: &DetectionContext,
1120 ) -> DetectionResult {
1121 if let Some((approval_type, details, rule)) = self.detect_approval(content) {
1123 trace!(rule, "detect_status: approval detected");
1124 let matched = safe_tail(content, 200);
1125 return DetectionResult::new(
1126 AgentStatus::AwaitingApproval {
1127 approval_type,
1128 details,
1129 },
1130 rule,
1131 DetectionConfidence::High,
1132 )
1133 .with_matched_text(matched);
1134 }
1135 trace!("detect_status: no approval detected, continuing to title/content checks");
1136
1137 {
1143 let title_activity = title
1144 .chars()
1145 .skip_while(|c| matches!(*c, '\u{2800}'..='\u{28FF}') || c.is_whitespace())
1146 .collect::<String>();
1147 if title.chars().any(|c| matches!(c, '\u{2800}'..='\u{28FF}')) {
1148 return DetectionResult::new(
1149 AgentStatus::Processing {
1150 activity: title_activity,
1151 },
1152 "title_braille_spinner_fast_path",
1153 DetectionConfidence::High,
1154 )
1155 .with_matched_text(title);
1156 }
1157 }
1158
1159 if let Some(message) = self.detect_error(content) {
1161 return DetectionResult::new(
1162 AgentStatus::Error {
1163 message: message.clone(),
1164 },
1165 "error_pattern",
1166 DetectionConfidence::High,
1167 )
1168 .with_matched_text(&message);
1169 }
1170
1171 if Self::has_in_progress_tasks(content) {
1173 return DetectionResult::new(
1174 AgentStatus::Processing {
1175 activity: "Tasks running".to_string(),
1176 },
1177 "tasks_in_progress",
1178 DetectionConfidence::High,
1179 );
1180 }
1181
1182 if title.contains('✽') && title.to_lowercase().contains("compacting") {
1184 return DetectionResult::new(
1185 AgentStatus::Processing {
1186 activity: "Compacting...".to_string(),
1187 },
1188 "title_compacting",
1189 DetectionConfidence::High,
1190 )
1191 .with_matched_text(title);
1192 }
1193
1194 {
1197 let recent = safe_tail(content, 1000);
1198 if recent.contains("Conversation compacted") {
1199 for line in recent
1201 .lines()
1202 .rev()
1203 .filter(|l| !l.trim().is_empty())
1204 .take(15)
1205 {
1206 let trimmed = line.trim();
1207 let first_char = trimmed.chars().next().unwrap_or('\0');
1208 if (CONTENT_SPINNER_CHARS.contains(&first_char) || first_char == '*')
1209 && trimmed.contains("Conversation compacted")
1210 {
1211 return DetectionResult::new(
1212 AgentStatus::Idle,
1213 "content_conversation_compacted",
1214 DetectionConfidence::High,
1215 )
1216 .with_matched_text(trimmed);
1217 }
1218 }
1219 }
1220 }
1221
1222 if let Some((activity, is_builtin)) = Self::detect_content_spinner(content, context) {
1226 let confidence = if is_builtin {
1227 DetectionConfidence::High
1228 } else {
1229 DetectionConfidence::Medium
1230 };
1231 return DetectionResult::new(
1232 AgentStatus::Processing {
1233 activity: activity.clone(),
1234 },
1235 "content_spinner_verb",
1236 confidence,
1237 )
1238 .with_matched_text(&activity);
1239 }
1240
1241 if let Some(matched) = Self::detect_turn_duration(content) {
1245 return DetectionResult::new(
1246 AgentStatus::Idle,
1247 "turn_duration_completed",
1248 DetectionConfidence::High,
1249 )
1250 .with_matched_text(&matched);
1251 }
1252
1253 if title.contains(IDLE_INDICATOR) {
1255 trace!(
1256 title,
1257 "detect_status: title_idle_indicator (approval was not detected)"
1258 );
1259 return DetectionResult::new(
1260 AgentStatus::Idle,
1261 "title_idle_indicator",
1262 DetectionConfidence::High,
1263 )
1264 .with_matched_text(title);
1265 }
1266
1267 if let Some(activity) = Self::detect_custom_spinner_verb(title, context) {
1269 return DetectionResult::new(
1270 AgentStatus::Processing { activity },
1271 "custom_spinner_verb",
1272 DetectionConfidence::Medium,
1273 )
1274 .with_matched_text(title);
1275 }
1276
1277 if !Self::should_skip_default_spinners(context)
1279 && title.chars().any(|c| PROCESSING_SPINNERS.contains(&c))
1280 {
1281 let activity = title
1282 .chars()
1283 .skip_while(|c| PROCESSING_SPINNERS.contains(c) || c.is_whitespace())
1284 .collect::<String>();
1285 return DetectionResult::new(
1286 AgentStatus::Processing { activity },
1287 "braille_spinner",
1288 DetectionConfidence::Medium,
1289 )
1290 .with_matched_text(title);
1291 }
1292
1293 DetectionResult::new(
1295 AgentStatus::Processing {
1296 activity: String::new(),
1297 },
1298 "fallback_no_indicator",
1299 DetectionConfidence::Low,
1300 )
1301 }
1302
1303 fn detect_context_warning(&self, content: &str) -> Option<u8> {
1304 for line in content.lines().rev().take(30) {
1306 if line.contains("Context left until auto-compact:") {
1307 if let Some(pct_str) = line.split(':').next_back() {
1309 let pct_str = pct_str.trim().trim_end_matches('%');
1310 if let Ok(pct) = pct_str.parse::<u8>() {
1311 return Some(pct);
1312 }
1313 }
1314 }
1315 }
1316 None
1317 }
1318
1319 fn agent_type(&self) -> AgentType {
1320 AgentType::ClaudeCode
1321 }
1322
1323 fn approval_keys(&self) -> &str {
1324 "Enter"
1327 }
1328 }
1330
1331fn safe_tail(s: &str, n: usize) -> &str {
1333 if s.len() <= n {
1334 s
1335 } else {
1336 let start = s.len() - n;
1337 let start = s
1339 .char_indices()
1340 .map(|(i, _)| i)
1341 .find(|&i| i >= start)
1342 .unwrap_or(s.len());
1343 &s[start..]
1344 }
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349 use super::*;
1350
1351 #[test]
1352 fn test_idle_with_asterisk() {
1353 let detector = ClaudeCodeDetector::new();
1354 let status = detector.detect_status("✳ Claude Code", "some content");
1355 assert!(matches!(status, AgentStatus::Idle));
1356 }
1357
1358 #[test]
1359 fn test_processing_with_spinner() {
1360 let detector = ClaudeCodeDetector::new();
1361 let status = detector.detect_status("⠋ Processing task", "some content");
1362 assert!(matches!(status, AgentStatus::Processing { .. }));
1363 }
1364
1365 #[test]
1366 fn test_yes_no_button_approval() {
1367 let detector = ClaudeCodeDetector::new();
1368 let content = r#"
1369Do you want to allow this action?
1370
1371 Yes
1372 Yes, and don't ask again for this session
1373 No
1374"#;
1375 let status = detector.detect_status("✳ Claude Code", content);
1376 assert!(matches!(status, AgentStatus::AwaitingApproval { .. }));
1377 }
1378
1379 #[test]
1380 fn test_no_false_positive_for_prompt() {
1381 let detector = ClaudeCodeDetector::new();
1382 let content = "Some previous output\n\n❯ ";
1384 let status = detector.detect_status("✳ Claude Code", content);
1385 assert!(matches!(status, AgentStatus::Idle));
1386 }
1387
1388 #[test]
1389 fn test_numbered_choices() {
1390 let detector = ClaudeCodeDetector::new();
1391 let content = r#"
1393Which option do you prefer?
1394
1395❯ 1. Option A
1396 2. Option B
1397 3. Option C
1398"#;
1399 let status = detector.detect_status("✳ Claude Code", content);
1400 match status {
1401 AgentStatus::AwaitingApproval { approval_type, .. } => {
1402 assert!(matches!(approval_type, ApprovalType::UserQuestion { .. }));
1403 }
1404 _ => panic!("Expected AwaitingApproval with UserQuestion"),
1405 }
1406 }
1407
1408 #[test]
1409 fn test_numbered_list_not_detected_as_question() {
1410 let detector = ClaudeCodeDetector::new();
1411 let content = r#"
1413Here are the changes:
1414
14151. Fixed the bug
14162. Added tests
14173. Updated docs
1418"#;
1419 let status = detector.detect_status("✳ Claude Code", content);
1420 assert!(matches!(status, AgentStatus::Idle));
1422 }
1423
1424 #[test]
1425 fn test_numbered_choices_with_cursor() {
1426 let detector = ClaudeCodeDetector::new();
1427 let content = r#"
1429Which option do you prefer?
1430
1431> 1. Option A
1432 2. Option B
1433 3. Option C
1434
1435❯
1436"#;
1437 let status = detector.detect_status("✳ Claude Code", content);
1438 match status {
1439 AgentStatus::AwaitingApproval { approval_type, .. } => {
1440 if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1441 assert_eq!(choices.len(), 3);
1442 } else {
1443 panic!("Expected UserQuestion");
1444 }
1445 }
1446 _ => panic!("Expected AwaitingApproval with UserQuestion"),
1447 }
1448 }
1449
1450 #[test]
1451 fn test_numbered_choices_with_descriptions() {
1452 let detector = ClaudeCodeDetector::new();
1453 let content = r#"
1455───────────────────────────────────────────────────────────────────────────────
1456 ☐ 動作確認
1457
1458数字キーで選択できますか?
1459
1460❯ 1. 1番: 動作した
1461 数字キーで1を押して選択できた
1462 2. 2番: まだ動かない
1463 数字キーが反応しない
1464 3. 3番: 別の問題
1465 他の問題が発生した
1466 4. Type something.
1467"#;
1468 let status = detector.detect_status("✳ Claude Code", content);
1469 match status {
1470 AgentStatus::AwaitingApproval { approval_type, .. } => {
1471 if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1472 assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
1473 } else {
1474 panic!("Expected UserQuestion, got {:?}", approval_type);
1475 }
1476 }
1477 _ => panic!("Expected AwaitingApproval, got {:?}", status),
1478 }
1479 }
1480
1481 #[test]
1482 fn test_would_you_like_to_proceed() {
1483 let detector = ClaudeCodeDetector::new();
1484 let content = r#"Would you like to proceed?
1485
1486 ❯ 1. Yes, clear context and auto-accept edits (shift+tab)
1487 2. Yes, auto-accept edits
1488 3. Yes, manually approve edits
1489 4. Type here to tell Claude what to change"#;
1490 let status = detector.detect_status("✳ Claude Code", content);
1491 match status {
1492 AgentStatus::AwaitingApproval { approval_type, .. } => {
1493 if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1494 assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
1495 } else {
1496 panic!("Expected UserQuestion, got {:?}", approval_type);
1497 }
1498 }
1499 _ => panic!("Expected AwaitingApproval, got {:?}", status),
1500 }
1501 }
1502
1503 #[test]
1504 fn test_would_you_like_to_proceed_with_footer() {
1505 let detector = ClaudeCodeDetector::new();
1506 let content = r#" - 環境変数未設定時に警告ログが出ることを確認
1508
1509 ---
1510 完了条件
1511
1512 - getInvitationLink ヘルパー関数を作成
1513 - queries.ts と mutations.ts でヘルパー関数を使用
1514 - 型チェック・リント・テストがパス
1515 - Issue #62 の関連項目をクローズ
1516╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
1517
1518 Would you like to proceed?
1519
1520 ❯ 1. Yes, clear context and auto-accept edits (shift+tab)
1521 2. Yes, auto-accept edits
1522 3. Yes, manually approve edits
1523 4. Type here to tell Claude what to change
1524
1525 ctrl-g to edit in Micro · .claude/plans/eventual-humming-hellman.md"#;
1526 let status = detector.detect_status("✳ Claude Code", content);
1527 match status {
1528 AgentStatus::AwaitingApproval { approval_type, .. } => {
1529 if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1530 assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
1531 } else {
1532 panic!("Expected UserQuestion, got {:?}", approval_type);
1533 }
1534 }
1535 _ => panic!("Expected AwaitingApproval, got {:?}", status),
1536 }
1537 }
1538
1539 #[test]
1540 fn test_numbered_choices_with_ui_hints() {
1541 let detector = ClaudeCodeDetector::new();
1542 let content = r#"
1544───────────────────────────────────────────────────────────────────────────────
1545 ☐ コンテンツ取得
1546
1547デバッグのため、コンテンツを貼り付けてもらえますか?
1548
1549❯ 1. 貼り付ける
1550 「その他」でコンテンツを入力
1551 2. 別のアプローチ
1552 デバッグモードを追加して原因を特定
1553 3. Type something.
1554
1555───────────────────────────────────────────────────────────────────────────────
1556 Chat about this
1557
1558Enter to select · ↑/↓ to navigate · Esc to cancel
1559"#;
1560 let status = detector.detect_status("✳ Claude Code", content);
1561 match status {
1562 AgentStatus::AwaitingApproval { approval_type, .. } => {
1563 if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1564 assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
1565 } else {
1566 panic!("Expected UserQuestion, got {:?}", approval_type);
1567 }
1568 }
1569 _ => panic!("Expected AwaitingApproval, got {:?}", status),
1570 }
1571 }
1572
1573 #[test]
1574 fn test_tasks_in_progress_detected_as_processing() {
1575 let detector = ClaudeCodeDetector::new();
1576 let content = r#"
1578 Tasks (0 done, 2 in progress, 8 open) · ctrl+t to hide tasks
1579 ◼ #1 T1: helpers仕様書の作成
1580 ◼ #2 T2: Result型仕様書の作成
1581 ◻ #3 T3: past-medication-record-edit更新
1582 ◻ #4 T4: medication-history更新
1583 ◻ #10 T10: OVERVIEW更新 › blocked by #9
1584"#;
1585 let status = detector.detect_status("✳ Claude Code", content);
1587 assert!(
1588 matches!(status, AgentStatus::Processing { .. }),
1589 "Expected Processing, got {:?}",
1590 status
1591 );
1592 }
1593
1594 #[test]
1595 fn test_tasks_in_progress_internal_format() {
1596 let detector = ClaudeCodeDetector::new();
1597 let content = r#"
1600 7 tasks (6 done, 1 in progress, 0 open)
1601 ✔ Fix 1: screen_context の機密情報サニタイズ
1602 ✔ Fix 2: in_flight/cooldowns の TOCTOU 修正
1603 ◼ 検証: cargo fmt, clippy, test, build
1604 ✔ Fix 4: judge.rs の stdout truncation
1605"#;
1606 let status = detector.detect_status("✳ Claude Code", content);
1607 assert!(
1608 matches!(status, AgentStatus::Processing { .. }),
1609 "Expected Processing for internal task format, got {:?}",
1610 status
1611 );
1612 }
1613
1614 #[test]
1615 fn test_tasks_in_progress_indicator_without_hash() {
1616 let detector = ClaudeCodeDetector::new();
1617 let content = "Some output\n ◼ Running tests\n ✔ Build passed\n";
1619 let status = detector.detect_status("✳ Claude Code", content);
1620 assert!(
1621 matches!(status, AgentStatus::Processing { .. }),
1622 "Expected Processing for ◼ without #N, got {:?}",
1623 status
1624 );
1625 }
1626
1627 #[test]
1628 fn test_tasks_all_done_is_idle() {
1629 let detector = ClaudeCodeDetector::new();
1630 let content = r#"
1632 Tasks (10 done, 0 in progress, 0 open) · ctrl+t to hide tasks
1633 ✔ #1 T1: helpers仕様書の作成
1634 ✔ #2 T2: Result型仕様書の作成
1635 ✔ #3 T3: past-medication-record-edit更新
1636"#;
1637 let status = detector.detect_status("✳ Claude Code", content);
1638 assert!(
1639 matches!(status, AgentStatus::Idle),
1640 "Expected Idle, got {:?}",
1641 status
1642 );
1643 }
1644
1645 #[test]
1646 fn test_tasks_all_done_internal_format_is_idle() {
1647 let detector = ClaudeCodeDetector::new();
1648 let content = r#"
1650 7 tasks (7 done, 0 in progress, 0 open)
1651 ✔ Fix 1: screen_context の機密情報サニタイズ
1652 ✔ Fix 2: in_flight/cooldowns の TOCTOU 修正
1653"#;
1654 let status = detector.detect_status("✳ Claude Code", content);
1655 assert!(
1656 matches!(status, AgentStatus::Idle),
1657 "Expected Idle for all-done internal format, got {:?}",
1658 status
1659 );
1660 }
1661
1662 #[test]
1663 fn test_web_search_approval() {
1664 let detector = ClaudeCodeDetector::new();
1665 let content = r#"● Web Search("MCP Apps iframe UI Model Context Protocol 2026")
1666
1667● Explore(プロジェクト構造の調査)
1668 ⎿ Done (11 tool uses · 85.3k tokens · 51s)
1669
1670───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1671 Tool use
1672
1673 Web Search("MCP Apps iframe UI Model Context Protocol 2026")
1674 Claude wants to search the web for: MCP Apps iframe UI Model Context Protocol 2026
1675
1676 Do you want to proceed?
1677 ❯ 1. Yes
1678 2. Yes, and don't ask again for Web Search commands in /home/trustdelta/works/conversation-handoff-mcp
1679 3. No
1680
1681 Esc to cancel · Tab to add additional instructions"#;
1682 let status = detector.detect_status("✳ Claude Code", content);
1683 assert!(
1684 matches!(status, AgentStatus::AwaitingApproval { .. }),
1685 "Expected AwaitingApproval, got {:?}",
1686 status
1687 );
1688 }
1689
1690 #[test]
1691 fn test_proceed_prompt_detection() {
1692 let detector = ClaudeCodeDetector::new();
1693 let content = r#"
1694 Do you want to proceed?
1695 ❯ 1. Yes
1696 2. Yes, and don't ask again for Web Search commands
1697 3. No
1698
1699 Esc to cancel"#;
1700 let status = detector.detect_status("✳ Claude Code", content);
1701 assert!(
1702 matches!(status, AgentStatus::AwaitingApproval { .. }),
1703 "Expected AwaitingApproval, got {:?}",
1704 status
1705 );
1706 }
1707
1708 #[test]
1709 fn test_actual_captured_content() {
1710 let detector = ClaudeCodeDetector::new();
1711 let content = "Line1\nLine2\nLine3\nLine4\nLine5\nLine6\n\
1713❯ MCP Appsが公開された、テスト\n\
1714Line8\nLine9\nLine10\n\
1715Line11\nLine12\nLine13\nLine14\nLine15\n\
1716 Tool use\n\
1717 Web Search(\"test\")\n\
1718\n\
1719 Do you want to proceed?\n\
1720 ❯ 1. Yes\n\
1721 2. No\n\
1722\n\
1723 Esc to cancel";
1724 let status = detector.detect_status("✳ Claude Code", content);
1725 assert!(
1726 matches!(status, AgentStatus::AwaitingApproval { .. }),
1727 "Expected AwaitingApproval, got {:?}",
1728 status
1729 );
1730 }
1731
1732 #[test]
1733 fn test_web_search_with_full_capture() {
1734 let detector = ClaudeCodeDetector::new();
1735 let content = r#"╭─── Claude Code v2.1.17 ─────────────────────────────────────────────────────────────────────────────────────────────╮
1737│ │ Tips for getting started │
1738│ Welcome back trust.delta! │ Run /init to create a CLAUDE.md file with instructions for Cl…│
1739│ │ │
1740│ │ ───────────────────────────────────────────────────────────── │
1741│ ▐▛███▜▌ │ Recent activity │
1742│ ▝▜█████▛▘ │ No recent activity │
1743│ ▘▘ ▝▝ │ │
1744│ Opus 4.5 · Claude Max · trust.delta@gmail.com's │ │
1745│ Organization │ │
1746│ ~/works/conversation-handoff-mcp │ │
1747╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
1748
1749❯ MCP Appsが公開された、mcpにiframeでuiを追加できる様子。実験がてらアプデが止まってたconversation-handoff-mcpに組
1750 み込んでみようと思います
1751
1752● MCP Appsは興味深い新機能ですね。まずMCP Appsの仕様と現在のconversation-handoff-mcpの状態を調査しましょう。
1753
1754● Web Search("MCP Apps iframe UI Model Context Protocol 2026")
1755
1756● Explore(プロジェクト構造の調査)
1757 ⎿ Done (11 tool uses · 85.3k tokens · 51s)
1758
1759───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1760 Tool use
1761
1762 Web Search("MCP Apps iframe UI Model Context Protocol 2026")
1763 Claude wants to search the web for: MCP Apps iframe UI Model Context Protocol 2026
1764
1765 Do you want to proceed?
1766 ❯ 1. Yes
1767 2. Yes, and don't ask again for Web Search commands in /home/trustdelta/works/conversation-handoff-mcp
1768 3. No
1769
1770 Esc to cancel · Tab to add additional instructions"#;
1771 let status = detector.detect_status("✳ Claude Code", content);
1772 assert!(
1773 matches!(status, AgentStatus::AwaitingApproval { .. }),
1774 "Expected AwaitingApproval, got {:?}",
1775 status
1776 );
1777 }
1778
1779 #[test]
1780 fn test_proceed_prompt_without_cursor_returns_user_question() {
1781 let detector = ClaudeCodeDetector::new();
1782 let content = r#"
1784 Tool use
1785
1786 Bash("ls -la")
1787
1788 Do you want to proceed?
1789 1. Yes
1790 2. Yes, and don't ask again for Bash commands
1791 3. No
1792
1793 Esc to cancel"#;
1794 let status = detector.detect_status("✳ Claude Code", content);
1795 match status {
1796 AgentStatus::AwaitingApproval { approval_type, .. } => {
1797 if let ApprovalType::UserQuestion {
1798 choices,
1799 multi_select,
1800 cursor_position,
1801 } = approval_type
1802 {
1803 assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
1804 assert!(!multi_select);
1805 assert_eq!(cursor_position, 1);
1806 assert!(choices[0].contains("Yes"));
1807 assert!(choices[2].contains("No"));
1808 } else {
1809 panic!(
1810 "Expected UserQuestion for cursor-less proceed prompt, got {:?}",
1811 approval_type
1812 );
1813 }
1814 }
1815 _ => panic!("Expected AwaitingApproval, got {:?}", status),
1816 }
1817 }
1818
1819 #[test]
1820 fn test_proceed_prompt_2_choice_without_cursor() {
1821 let detector = ClaudeCodeDetector::new();
1822 let content = r#" Do you want to proceed?
1824 1. Yes
1825 2. No"#;
1826 let status = detector.detect_status("✳ Claude Code", content);
1827 match status {
1828 AgentStatus::AwaitingApproval { approval_type, .. } => {
1829 if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1830 assert_eq!(choices.len(), 2, "Expected 2 choices, got {:?}", choices);
1831 } else {
1832 panic!("Expected UserQuestion, got {:?}", approval_type);
1833 }
1834 }
1835 _ => panic!("Expected AwaitingApproval, got {:?}", status),
1836 }
1837 }
1838
1839 #[test]
1840 fn test_custom_spinner_verb_detection_replace_mode() {
1841 use crate::config::ClaudeSettingsCache;
1842
1843 let detector = ClaudeCodeDetector::new();
1844 let cache = ClaudeSettingsCache::new();
1845
1846 let context = DetectionContext {
1851 cwd: None, settings_cache: Some(&cache),
1853 };
1854
1855 let status =
1857 detector.detect_status_with_context("Thinking about code", "content", &context);
1858 assert!(
1860 matches!(status, AgentStatus::Processing { .. }),
1861 "Expected Processing, got {:?}",
1862 status
1863 );
1864 }
1865
1866 #[test]
1867 fn test_default_spinner_still_works_without_settings() {
1868 let detector = ClaudeCodeDetector::new();
1869 let context = DetectionContext::default();
1870
1871 let status = detector.detect_status_with_context("⠋ Working on task", "content", &context);
1873 match status {
1874 AgentStatus::Processing { activity } => {
1875 assert_eq!(activity, "Working on task");
1876 }
1877 _ => panic!("Expected Processing, got {:?}", status),
1878 }
1879 }
1880
1881 #[test]
1882 fn test_simple_yes_no_proceed() {
1883 let detector = ClaudeCodeDetector::new();
1884 let content = r#" Do you want to proceed?
1886 ❯ 1. Yes
1887 2. No"#;
1888 let status = detector.detect_status("✳ Claude Code", content);
1889 assert!(
1890 matches!(status, AgentStatus::AwaitingApproval { .. }),
1891 "Expected AwaitingApproval, got {:?}",
1892 status
1893 );
1894 }
1895
1896 #[test]
1897 fn test_content_spinner_overrides_title_idle() {
1898 let detector = ClaudeCodeDetector::new();
1899 let content = r#"
1902✻ Cogitated for 2m 6s
1903
1904❯ コミットしてdev-log
1905
1906✶ Spinning… (37s · ↑ 38 tokens)
1907
1908Some other output here
1909"#;
1910 let result = detector.detect_status_with_reason(
1911 "✳ Git commit dev-log",
1912 content,
1913 &DetectionContext::default(),
1914 );
1915 assert!(
1916 matches!(result.status, AgentStatus::Processing { .. }),
1917 "Expected Processing when content has active spinner, got {:?}",
1918 result.status
1919 );
1920 assert_eq!(result.reason.rule, "content_spinner_verb");
1921 assert_eq!(result.reason.confidence, DetectionConfidence::High);
1923 }
1924
1925 #[test]
1926 fn test_content_spinner_with_four_teardrop() {
1927 let detector = ClaudeCodeDetector::new();
1928 let content = "Some output\n\n✢ Bootstrapping… (1m 27s)\n\nMore output\n";
1931 let result = detector.detect_status_with_reason(
1932 "✳ Task name",
1933 content,
1934 &DetectionContext::default(),
1935 );
1936 assert!(
1937 matches!(result.status, AgentStatus::Processing { .. }),
1938 "Expected Processing for ✢ spinner, got {:?}",
1939 result.status
1940 );
1941 assert_eq!(result.reason.rule, "content_spinner_verb");
1942 }
1943
1944 #[test]
1945 fn test_content_spinner_with_plain_asterisk() {
1946 let detector = ClaudeCodeDetector::new();
1947 let content = "Some output\n\n* Perambulating…\n\nMore output\n";
1950 let result = detector.detect_status_with_reason(
1951 "✳ Task name",
1952 content,
1953 &DetectionContext::default(),
1954 );
1955 assert!(
1956 matches!(result.status, AgentStatus::Processing { .. }),
1957 "Expected Processing for * spinner, got {:?}",
1958 result.status
1959 );
1960 }
1961
1962 #[test]
1963 fn test_completed_spinner_not_detected_as_active() {
1964 let detector = ClaudeCodeDetector::new();
1965 let content = "Some output\n\n✻ Crunched for 6m 5s\n\n❯ \n";
1967 let result = detector.detect_status_with_reason(
1968 "✳ Task name",
1969 content,
1970 &DetectionContext::default(),
1971 );
1972 assert!(
1973 matches!(result.status, AgentStatus::Idle),
1974 "Expected Idle for completed spinner, got {:?}",
1975 result.status
1976 );
1977 }
1978
1979 #[test]
1980 fn test_detect_status_with_context_backwards_compatible() {
1981 let detector = ClaudeCodeDetector::new();
1982 let context = DetectionContext::default();
1983
1984 let title = "✳ Claude Code";
1987 let content = "some content";
1988
1989 let status1 = detector.detect_status(title, content);
1990 let status2 = detector.detect_status_with_context(title, content, &context);
1991
1992 assert!(matches!(status1, AgentStatus::Idle));
1994 assert!(matches!(status2, AgentStatus::Idle));
1995 }
1996
1997 #[test]
1998 fn test_multi_select_with_trailing_empty_lines() {
1999 let detector = ClaudeCodeDetector::new();
2000 let content = "\
2004今日の作業内容を教えてください(複数選択可)\n\
2005\n\
2006❯ 1. [ ] 機能実装\n\
2007 --audit モードの実装\n\
2008 2. [ ] ドキュメント更新\n\
2009 CHANGELOG, README, CLAUDE.md更新\n\
2010 3. [ ] CI/CD構築\n\
2011 タグプッシュ時の自動npm publishワークフロー作成\n\
2012 4. [ ] リリース\n\
2013 v0.7.0のnpm publish\n\
2014 5. [ ] Type something\n\
2015 Submit\n\
2016──────────────────────────────────────────\n\
2017 6. Chat about this\n\
2018\n\
2019Enter to select · ↑/↓ to navigate · Esc to cancel\n\
2020\n\n\n\n\n\n\n\n\n\n\n\n\n\n";
2021 let status = detector.detect_status("✳ Dev Log", content);
2022 assert!(
2023 matches!(status, AgentStatus::AwaitingApproval { .. }),
2024 "Should detect AskUserQuestion despite trailing empty lines, got {:?}",
2025 status
2026 );
2027 if let AgentStatus::AwaitingApproval { approval_type, .. } = status {
2028 if let ApprovalType::UserQuestion {
2029 choices,
2030 multi_select,
2031 cursor_position,
2032 ..
2033 } = approval_type
2034 {
2035 assert_eq!(choices.len(), 6, "Expected 6 choices, got {:?}", choices);
2036 let _ = multi_select;
2039 assert_eq!(cursor_position, 1);
2040 } else {
2041 panic!("Expected UserQuestion, got {:?}", approval_type);
2042 }
2043 }
2044 }
2045
2046 #[test]
2047 fn test_content_spinner_not_detected_when_idle_prompt_present() {
2048 let detector = ClaudeCodeDetector::new();
2049 let content = "Some output\n\n✽ Forging… (2m 3s)\n\nMore output\n\n❯ \n";
2051 let result = detector.detect_status_with_reason(
2052 "✳ Task name",
2053 content,
2054 &DetectionContext::default(),
2055 );
2056 assert!(
2057 matches!(result.status, AgentStatus::Idle),
2058 "Expected Idle when ❯ prompt is present below old spinner, got {:?}",
2059 result.status
2060 );
2061 }
2062
2063 #[test]
2064 fn test_actual_title_spinner_chars() {
2065 let detector = ClaudeCodeDetector::new();
2066 for (spinner, label) in [('⠂', "U+2802"), ('⠐', "U+2810")] {
2068 let title = format!("{} Working on task", spinner);
2069 let result = detector.detect_status_with_reason(
2070 &title,
2071 "some content",
2072 &DetectionContext::default(),
2073 );
2074 assert!(
2075 matches!(result.status, AgentStatus::Processing { .. }),
2076 "Expected Processing for {} ({}), got {:?}",
2077 spinner,
2078 label,
2079 result.status
2080 );
2081 assert_eq!(
2082 result.reason.rule, "title_braille_spinner_fast_path",
2083 "Expected title_braille_spinner_fast_path rule for {} ({})",
2084 spinner, label
2085 );
2086 }
2087 }
2088
2089 #[test]
2090 fn test_content_spinner_with_empty_line_padding() {
2091 let detector = ClaudeCodeDetector::new();
2092 let content = "Some output\n\n✶ Bootstrapping… (5s)\n\n\n\n\n\n\n\n\n\n\n\n";
2094 let result = detector.detect_status_with_reason(
2095 "✳ Task name",
2096 content,
2097 &DetectionContext::default(),
2098 );
2099 assert!(
2100 matches!(result.status, AgentStatus::Processing { .. }),
2101 "Expected Processing when spinner is followed by empty line padding, got {:?}",
2102 result.status
2103 );
2104 assert_eq!(result.reason.rule, "content_spinner_verb");
2105 }
2106
2107 #[test]
2108 fn test_content_spinner_beyond_old_window() {
2109 let detector = ClaudeCodeDetector::new();
2110 let mut content = String::from("Some output\n\n✻ Levitating… (10s)\n");
2113 for _ in 0..10 {
2115 content.push('\n');
2116 }
2117 content.push_str("───────────────────────\n");
2118 content.push_str(" ctrl-g to edit\n");
2119 content.push_str(" Status bar line\n");
2120 for _ in 0..5 {
2121 content.push('\n');
2122 }
2123 let result = detector.detect_status_with_reason(
2124 "✳ Task name",
2125 &content,
2126 &DetectionContext::default(),
2127 );
2128 assert!(
2129 matches!(result.status, AgentStatus::Processing { .. }),
2130 "Expected Processing when spinner is beyond old 15-line window, got {:?}",
2131 result.status
2132 );
2133 assert_eq!(result.reason.rule, "content_spinner_verb");
2134 }
2135
2136 #[test]
2137 fn test_idle_prompt_detection_with_empty_lines() {
2138 let detector = ClaudeCodeDetector::new();
2139 let content = "Some output\n\n✶ Spinning… (5s)\n\nMore output\n\n❯ \n\n\n\n\n\n\n\n\n\n\n";
2141 let result = detector.detect_status_with_reason(
2142 "✳ Task name",
2143 content,
2144 &DetectionContext::default(),
2145 );
2146 assert!(
2147 matches!(result.status, AgentStatus::Idle),
2148 "Expected Idle when ❯ prompt is present (even with empty line padding), got {:?}",
2149 result.status
2150 );
2151 }
2152
2153 #[test]
2154 fn test_content_spinner_with_idle_indicator_char() {
2155 let detector = ClaudeCodeDetector::new();
2156 let content = "Some output\n\n✳ Ruminating… (3s)\n\nMore output\n";
2159 let result = detector.detect_status_with_reason(
2160 "Claude Code", content,
2162 &DetectionContext::default(),
2163 );
2164 assert!(
2165 matches!(result.status, AgentStatus::Processing { .. }),
2166 "Expected Processing for ✳ content spinner, got {:?}",
2167 result.status
2168 );
2169 assert_eq!(result.reason.rule, "content_spinner_verb");
2170 }
2171
2172 #[test]
2173 fn test_multi_select_windows_checkbox() {
2174 let detector = ClaudeCodeDetector::new();
2175 let content = r#"
2177Which items to include?
2178
2179❯ 1. [×] Feature A
2180 2. [ ] Feature B
2181 3. [×] Feature C
2182 4. Type something.
2183"#;
2184 let status = detector.detect_status("✳ Claude Code", content);
2185 match status {
2186 AgentStatus::AwaitingApproval { approval_type, .. } => {
2187 if let ApprovalType::UserQuestion {
2188 choices,
2189 multi_select,
2190 ..
2191 } = approval_type
2192 {
2193 assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
2194 assert!(multi_select, "Expected multi_select=true for [×] checkbox");
2195 } else {
2196 panic!("Expected UserQuestion, got {:?}", approval_type);
2197 }
2198 }
2199 _ => panic!("Expected AwaitingApproval, got {:?}", status),
2200 }
2201 }
2202
2203 #[test]
2204 fn test_mode_detection_plan() {
2205 assert_eq!(
2206 ClaudeCodeDetector::detect_mode("⏸ ✳ Claude Code"),
2207 AgentMode::Plan
2208 );
2209 assert_eq!(
2210 ClaudeCodeDetector::detect_mode("⏸ ⠂ Working on task"),
2211 AgentMode::Plan
2212 );
2213 }
2214
2215 #[test]
2216 fn test_mode_detection_delegate() {
2217 assert_eq!(
2218 ClaudeCodeDetector::detect_mode("⇢ ✳ Claude Code"),
2219 AgentMode::Delegate
2220 );
2221 }
2222
2223 #[test]
2224 fn test_mode_detection_auto_approve() {
2225 assert_eq!(
2226 ClaudeCodeDetector::detect_mode("⏵⏵ ✳ Claude Code"),
2227 AgentMode::AutoApprove
2228 );
2229 assert_eq!(
2230 ClaudeCodeDetector::detect_mode("⏵⏵ ⠐ Processing"),
2231 AgentMode::AutoApprove
2232 );
2233 }
2234
2235 #[test]
2236 fn test_mode_detection_default() {
2237 assert_eq!(
2238 ClaudeCodeDetector::detect_mode("✳ Claude Code"),
2239 AgentMode::Default
2240 );
2241 assert_eq!(
2242 ClaudeCodeDetector::detect_mode("⠂ Working"),
2243 AgentMode::Default
2244 );
2245 }
2246
2247 #[test]
2248 fn test_turn_duration_cooked() {
2249 let detector = ClaudeCodeDetector::new();
2250 let content = "Some output\n\n✻ Cooked for 1m 6s\n\nSome status bar\n";
2252 let result = detector.detect_status_with_reason(
2253 "✳ Task name",
2254 content,
2255 &DetectionContext::default(),
2256 );
2257 assert!(
2258 matches!(result.status, AgentStatus::Idle),
2259 "Expected Idle for turn duration, got {:?}",
2260 result.status
2261 );
2262 assert_eq!(result.reason.rule, "turn_duration_completed");
2263 assert_eq!(result.reason.confidence, DetectionConfidence::High);
2264 }
2265
2266 #[test]
2267 fn test_turn_duration_brewed() {
2268 let detector = ClaudeCodeDetector::new();
2269 let content = "Output\n\n✻ Brewed for 42s\n\n";
2270 let result = detector.detect_status_with_reason(
2271 "✳ Claude Code",
2272 content,
2273 &DetectionContext::default(),
2274 );
2275 assert!(
2276 matches!(result.status, AgentStatus::Idle),
2277 "Expected Idle for Brewed duration, got {:?}",
2278 result.status
2279 );
2280 assert_eq!(result.reason.rule, "turn_duration_completed");
2281 }
2282
2283 #[test]
2284 fn test_turn_duration_sauteed() {
2285 let detector = ClaudeCodeDetector::new();
2286 let content = "Output\n\n✶ Sautéed for 3m 12s\n\n";
2288 let result = detector.detect_status_with_reason(
2289 "✳ Claude Code",
2290 content,
2291 &DetectionContext::default(),
2292 );
2293 assert!(
2294 matches!(result.status, AgentStatus::Idle),
2295 "Expected Idle for Sautéed duration, got {:?}",
2296 result.status
2297 );
2298 }
2299
2300 #[test]
2301 fn test_turn_duration_does_not_match_active_spinner() {
2302 let detector = ClaudeCodeDetector::new();
2303 let content = "Output\n\n✻ Cooking… (5s)\n\n";
2305 let result = detector.detect_status_with_reason(
2306 "✳ Claude Code",
2307 content,
2308 &DetectionContext::default(),
2309 );
2310 assert!(
2312 matches!(result.status, AgentStatus::Processing { .. }),
2313 "Expected Processing for active spinner, got {:?}",
2314 result.status
2315 );
2316 }
2317
2318 #[test]
2319 fn test_conversation_compacted_in_content() {
2320 let detector = ClaudeCodeDetector::new();
2321 let content =
2322 "Some output\n\n✻ Conversation compacted (ctrl+o for history)\n\nStatus bar\n";
2323 let result = detector.detect_status_with_reason(
2324 "✳ Claude Code",
2325 content,
2326 &DetectionContext::default(),
2327 );
2328 assert!(
2329 matches!(result.status, AgentStatus::Idle),
2330 "Expected Idle for Conversation compacted, got {:?}",
2331 result.status
2332 );
2333 assert_eq!(result.reason.rule, "content_conversation_compacted");
2334 assert_eq!(result.reason.confidence, DetectionConfidence::High);
2335 }
2336
2337 #[test]
2338 fn test_conversation_compacted_title_still_works() {
2339 let detector = ClaudeCodeDetector::new();
2340 let content = "Some content\n";
2342 let result = detector.detect_status_with_reason(
2343 "✽ Compacting conversation",
2344 content,
2345 &DetectionContext::default(),
2346 );
2347 assert!(
2348 matches!(result.status, AgentStatus::Processing { .. }),
2349 "Expected Processing for title compacting, got {:?}",
2350 result.status
2351 );
2352 assert_eq!(result.reason.rule, "title_compacting");
2353 }
2354
2355 #[test]
2356 fn test_builtin_spinner_verb_high_confidence() {
2357 let detector = ClaudeCodeDetector::new();
2358 let content = "Some output\n\n✶ Spinning… (5s)\n\nMore output\n";
2360 let result = detector.detect_status_with_reason(
2361 "Claude Code", content,
2363 &DetectionContext::default(),
2364 );
2365 assert!(
2366 matches!(result.status, AgentStatus::Processing { .. }),
2367 "Expected Processing, got {:?}",
2368 result.status
2369 );
2370 assert_eq!(result.reason.rule, "content_spinner_verb");
2371 assert_eq!(result.reason.confidence, DetectionConfidence::High);
2372 }
2373
2374 #[test]
2375 fn test_unknown_spinner_verb_medium_confidence() {
2376 let detector = ClaudeCodeDetector::new();
2377 let content = "Some output\n\n✶ Zazzlefrazzing… (5s)\n\nMore output\n";
2379 let result = detector.detect_status_with_reason(
2380 "Claude Code", content,
2382 &DetectionContext::default(),
2383 );
2384 assert!(
2385 matches!(result.status, AgentStatus::Processing { .. }),
2386 "Expected Processing, got {:?}",
2387 result.status
2388 );
2389 assert_eq!(result.reason.rule, "content_spinner_verb");
2390 assert_eq!(result.reason.confidence, DetectionConfidence::Medium);
2391 }
2392
2393 #[test]
2394 fn test_builtin_verb_flambeing_with_accent() {
2395 let detector = ClaudeCodeDetector::new();
2396 let content = "Output\n\n✻ Flambéing… (2s)\n\n";
2398 let result = detector.detect_status_with_reason(
2399 "⠂ Task name",
2400 content,
2401 &DetectionContext::default(),
2402 );
2403 assert_eq!(result.reason.confidence, DetectionConfidence::High);
2404 }
2405
2406 #[test]
2407 fn test_windows_ascii_radio_buttons() {
2408 let detector = ClaudeCodeDetector::new();
2409 let content = r#"
2411Which option?
2412
2413❯ 1. (*) Option A
2414 2. ( ) Option B
2415 3. ( ) Option C
2416"#;
2417 let status = detector.detect_status("✳ Claude Code", content);
2418 match status {
2419 AgentStatus::AwaitingApproval { approval_type, .. } => {
2420 if let ApprovalType::UserQuestion {
2421 choices,
2422 multi_select,
2423 ..
2424 } = approval_type
2425 {
2426 assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
2427 assert!(
2428 !multi_select,
2429 "Expected multi_select=false for (*) radio buttons (single-select)"
2430 );
2431 } else {
2432 panic!("Expected UserQuestion, got {:?}", approval_type);
2433 }
2434 }
2435 _ => panic!("Expected AwaitingApproval, got {:?}", status),
2436 }
2437 }
2438
2439 #[test]
2440 fn test_preview_format_with_single_right_angle() {
2441 let detector = ClaudeCodeDetector::new();
2442 let content = r#"
2444Which approach do you prefer?
2445
2446 1. Base directories ┌──────────────────────┐
2447› 2. Bookmark style │ # config.toml │
2448 3. Both │ [create_process] │
2449 4. Default input │ directories = [...] │
2450 └──────────────────────┘
2451
2452 Chat about this
2453
2454Enter to select · ↑/↓ to navigate · n to add notes · Esc to cancel
2455"#;
2456 let status = detector.detect_status("✳ Claude Code", content);
2457 match status {
2458 AgentStatus::AwaitingApproval { approval_type, .. } => {
2459 if let ApprovalType::UserQuestion {
2460 choices,
2461 multi_select,
2462 cursor_position,
2463 } = approval_type
2464 {
2465 assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
2466 assert_eq!(cursor_position, 2, "Cursor should be on choice 2");
2467 assert!(
2468 !multi_select,
2469 "Preview format should not be detected as multi-select"
2470 );
2471 assert!(
2473 !choices[0].contains('│'),
2474 "Choice text should not contain box chars: {:?}",
2475 choices[0]
2476 );
2477 } else {
2478 panic!("Expected UserQuestion, got {:?}", approval_type);
2479 }
2480 }
2481 _ => panic!("Expected AwaitingApproval, got {:?}", status),
2482 }
2483 }
2484
2485 #[test]
2486 fn test_preview_format_large_box() {
2487 let detector = ClaudeCodeDetector::new();
2488 let content = r#"
2491Previous conversation...
2492
2493────────────────────────────────────────────────────────────────────────
2494Which configuration format do you prefer?
2495
2496 1. TOML format ┌──────────────────────────────┐
2497› 2. YAML format │ # Example YAML config │
2498 3. JSON format │ server: │
2499 │ host: localhost │
2500 │ port: 8080 │
2501 │ workers: 4 │
2502 │ database: │
2503 │ url: postgres://localhost │
2504 │ pool_size: 10 │
2505 │ timeout: 30s │
2506 │ logging: │
2507 │ level: info │
2508 │ format: json │
2509 └──────────────────────────────┘
2510
2511────────────────────────────────────────────────────────────────────────
2512 Chat about this
2513
2514Enter to select · ↑/↓ to navigate · n to add notes · Esc to cancel
2515"#;
2516 let mut padded = content.to_string();
2518 for _ in 0..30 {
2519 padded.push('\n');
2520 }
2521
2522 let status = detector.detect_status("✳ Claude Code", &padded);
2523 match status {
2524 AgentStatus::AwaitingApproval { approval_type, .. } => {
2525 if let ApprovalType::UserQuestion {
2526 choices,
2527 multi_select,
2528 cursor_position,
2529 } = approval_type
2530 {
2531 assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
2532 assert_eq!(cursor_position, 2, "Cursor should be on choice 2");
2533 assert!(
2534 !multi_select,
2535 "Preview format should not be detected as multi-select"
2536 );
2537 assert!(
2538 !choices[0].contains('│'),
2539 "Choice text should not contain box chars: {:?}",
2540 choices[0]
2541 );
2542 } else {
2543 panic!("Expected UserQuestion, got {:?}", approval_type);
2544 }
2545 }
2546 _ => panic!(
2547 "Expected AwaitingApproval for large preview box, got {:?}",
2548 status
2549 ),
2550 }
2551 }
2552
2553 #[test]
2554 fn test_preview_format_very_large_box_real_capture() {
2555 let detector = ClaudeCodeDetector::new();
2556 let content = "\
2562Previous conversation output here...
2563
2564✻ Worked for 30s
2565
2566────────────────────────────────────────────────────────────────────────────────
2567 Capture Feedback
2568
2569伝えたいことを教えてください。大きいプレビューにカーソルを合わせて確認してください。
2570
2571 1. Option A ┌──────────────────────────────────────────┐
2572› 2. Option B │ # Large Preview B │
2573 3. Option C │ │
2574 │ ## Database Schema │
2575 │ │
2576 │ ```sql │
2577 │ CREATE TABLE users ( │
2578 │ id UUID PRIMARY KEY, │
2579 │ email TEXT UNIQUE, │
2580 │ name TEXT, │
2581 │ created_at TIMESTAMPTZ │
2582 │ ); │
2583 │ │
2584 │ CREATE TABLE teams ( │
2585 │ id UUID PRIMARY KEY, │
2586 │ name TEXT, │
2587 │ owner_id UUID REFERENCES users │
2588 │ ); │
2589 │ │
2590 │ CREATE TABLE members ( │
2591 │ team_id UUID REFERENCES teams, │
2592 │ user_id UUID REFERENCES users, │
2593 │ role TEXT DEFAULT 'member', │
2594 │ PRIMARY KEY (team_id, user_id) │
2595 │ ); │
2596 │ ``` │
2597 └──────────────────────────────────────────┘
2598
2599 Notes: press n to add notes
2600
2601────────────────────────────────────────────────────────────────────────────────
2602 Chat about this
2603
2604Enter to select · ↑/↓ to navigate · n to add notes · Esc to cancel
2605";
2606 let status = detector.detect_status("✳ Claude Code", content);
2607 match status {
2608 AgentStatus::AwaitingApproval { approval_type, .. } => {
2609 if let ApprovalType::UserQuestion {
2610 choices,
2611 cursor_position,
2612 ..
2613 } = approval_type
2614 {
2615 assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
2616 assert_eq!(cursor_position, 2, "Cursor should be on choice 2");
2617 } else {
2618 panic!("Expected UserQuestion, got {:?}", approval_type);
2619 }
2620 }
2621 _ => panic!(
2622 "Expected AwaitingApproval for very large preview box, got {:?}",
2623 status
2624 ),
2625 }
2626 }
2627}