1use crate::output::{DIM, GRAY, GREEN, RED, RESET, YELLOW};
2use indicatif::{ProgressBar, ProgressStyle};
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::thread::{self, JoinHandle};
6use std::time::{Duration, Instant};
7use terminal_size::{terminal_size, Width};
8
9const SPINNER_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
10const DEFAULT_TERMINAL_WIDTH: u16 = 80;
11pub const ACTIVITY_TEXT_WIDTH: usize = 40;
13
14#[derive(Debug, Clone, Default)]
20pub struct IterationInfo {
21 pub current: Option<u32>,
23 pub total: Option<u32>,
25 pub phase: Option<String>,
27}
28
29#[derive(Debug, Clone, Default)]
42pub struct ProgressContext {
43 pub story_index: Option<u32>,
45 pub total_stories: Option<u32>,
47 pub story_id: Option<String>,
49 pub current_phase: Option<String>,
51}
52
53impl ProgressContext {
54 pub fn new(story_id: &str, story_index: u32, total_stories: u32) -> Self {
56 Self {
57 story_index: Some(story_index),
58 total_stories: Some(total_stories),
59 story_id: Some(story_id.to_string()),
60 current_phase: None,
61 }
62 }
63
64 pub fn with_phase(story_id: &str, story_index: u32, total_stories: u32, phase: &str) -> Self {
66 Self {
67 story_index: Some(story_index),
68 total_stories: Some(total_stories),
69 story_id: Some(story_id.to_string()),
70 current_phase: Some(phase.to_string()),
71 }
72 }
73
74 pub fn set_phase(&mut self, phase: &str) {
76 self.current_phase = Some(phase.to_string());
77 }
78
79 pub fn format_story_progress(&self) -> Option<String> {
81 match (&self.story_id, self.story_index, self.total_stories) {
82 (Some(id), Some(idx), Some(total)) => Some(format!("[{} {}/{}]", id, idx, total)),
83 _ => None,
84 }
85 }
86
87 pub fn format_dual_context(&self, iteration_info: &Option<IterationInfo>) -> Option<String> {
92 let story_part = self.format_story_progress();
93 let iter_part = iteration_info.as_ref().and_then(|info| info.format());
94
95 match (story_part, iter_part) {
96 (Some(story), Some(iter)) => {
97 let story_inner = story.trim_start_matches('[').trim_end_matches(']');
99 let iter_inner = iter.trim_start_matches('[').trim_end_matches(']');
100 Some(format!("[{} | {}]", story_inner, iter_inner))
101 }
102 (Some(story), None) => Some(story),
103 (None, Some(iter)) => Some(iter),
104 (None, None) => None,
105 }
106 }
107}
108
109impl IterationInfo {
110 pub fn new(current: u32, total: u32) -> Self {
112 Self {
113 current: Some(current),
114 total: Some(total),
115 phase: None,
116 }
117 }
118
119 pub fn with_phase(phase: &str, current: u32, total: u32) -> Self {
121 Self {
122 current: Some(current),
123 total: Some(total),
124 phase: Some(phase.to_string()),
125 }
126 }
127
128 pub fn phase_only(phase: &str) -> Self {
130 Self {
131 current: None,
132 total: None,
133 phase: Some(phase.to_string()),
134 }
135 }
136
137 pub fn format(&self) -> Option<String> {
140 match (&self.phase, self.current, self.total) {
141 (Some(phase), Some(curr), Some(tot)) => Some(format!("[{} {}/{}]", phase, curr, tot)),
142 (Some(phase), None, None) => Some(format!("[{}]", phase)),
143 (None, Some(curr), Some(tot)) => Some(format!("[{}/{}]", curr, tot)),
144 _ => None,
145 }
146 }
147}
148
149#[derive(Debug, Clone)]
151pub struct Outcome {
152 pub success: bool,
154 pub message: String,
156 pub tokens: Option<u64>,
158}
159
160impl Outcome {
161 pub fn success(message: impl Into<String>) -> Self {
163 Self {
164 success: true,
165 message: message.into(),
166 tokens: None,
167 }
168 }
169
170 pub fn failure(message: impl Into<String>) -> Self {
172 Self {
173 success: false,
174 message: message.into(),
175 tokens: None,
176 }
177 }
178
179 pub fn with_tokens(mut self, tokens: u64) -> Self {
181 self.tokens = Some(tokens);
182 self
183 }
184
185 pub fn with_optional_tokens(mut self, tokens: Option<u64>) -> Self {
187 self.tokens = tokens;
188 self
189 }
190}
191
192pub trait AgentDisplay {
203 fn start(&mut self);
206
207 fn update(&mut self, activity: &str);
212
213 fn finish_success(&mut self);
216
217 fn finish_error(&mut self, error: &str);
223
224 fn finish_with_outcome(&mut self, outcome: Outcome);
230
231 fn agent_name(&self) -> &str;
233
234 fn elapsed_secs(&self) -> u64;
236
237 fn iteration_info(&self) -> Option<&IterationInfo>;
239
240 fn set_iteration_info(&mut self, info: IterationInfo);
242}
243
244pub trait AgentDisplayExt: AgentDisplay {
246 fn complete_success(&mut self) {
248 AgentDisplay::finish_success(self);
249 }
250
251 fn complete_error(&mut self, error: &str) {
253 AgentDisplay::finish_error(self, error);
254 }
255
256 fn complete_with_outcome(&mut self, outcome: Outcome) {
258 AgentDisplay::finish_with_outcome(self, outcome);
259 }
260}
261
262impl<T: AgentDisplay> AgentDisplayExt for T {}
263
264pub struct VerboseTimer {
271 story_id: String,
272 stop_flag: Arc<AtomicBool>,
273 timer_thread: Option<JoinHandle<()>>,
274 start_time: Instant,
275 iteration_info: Option<IterationInfo>,
276 started: bool,
277}
278
279impl VerboseTimer {
280 pub fn new(story_id: &str) -> Self {
281 let stop_flag = Arc::new(AtomicBool::new(false));
282 let start_time = Instant::now();
283
284 let stop_flag_clone = Arc::clone(&stop_flag);
285 let story_id_owned = story_id.to_string();
286
287 let timer_thread = thread::spawn(move || {
289 let mut last_print = Instant::now();
290 while !stop_flag_clone.load(Ordering::Relaxed) {
291 thread::sleep(Duration::from_millis(500));
292
293 if stop_flag_clone.load(Ordering::Relaxed) {
294 break;
295 }
296
297 if last_print.elapsed().as_secs() >= 10 {
299 let elapsed = start_time.elapsed();
300 let hours = elapsed.as_secs() / 3600;
301 let mins = (elapsed.as_secs() % 3600) / 60;
302 let secs = elapsed.as_secs() % 60;
303 eprintln!(
304 "{DIM}[{} elapsed: {:02}:{:02}:{:02}]{RESET}",
305 story_id_owned, hours, mins, secs
306 );
307 last_print = Instant::now();
308 }
309 }
310 });
311
312 Self {
313 story_id: story_id.to_string(),
314 stop_flag,
315 timer_thread: Some(timer_thread),
316 start_time,
317 iteration_info: None,
318 started: true,
319 }
320 }
321
322 pub fn new_with_story_progress(story_id: &str, current: u32, total: u32) -> Self {
324 let mut timer = Self::new(story_id);
325 timer.iteration_info = Some(IterationInfo::with_phase(story_id, current, total));
326 timer
327 }
328
329 pub fn new_for_review(current: u32, total: u32) -> Self {
331 let mut timer = Self::new("Review");
332 timer.iteration_info = Some(IterationInfo::with_phase("Review", current, total));
333 timer
334 }
335
336 pub fn new_for_correct(current: u32, total: u32) -> Self {
338 let mut timer = Self::new("Correct");
339 timer.iteration_info = Some(IterationInfo::with_phase("Correct", current, total));
340 timer
341 }
342
343 pub fn new_for_spec() -> Self {
344 Self::new("Spec generation")
345 }
346
347 pub fn new_for_commit() -> Self {
349 let mut timer = Self::new("Commit");
350 timer.iteration_info = Some(IterationInfo::phase_only("Commit"));
351 timer
352 }
353
354 fn stop_timer(&mut self) {
355 self.stop_flag.store(true, Ordering::Relaxed);
356 if let Some(handle) = self.timer_thread.take() {
357 let _ = handle.join();
358 }
359 }
360
361 pub fn finish_success(&mut self) {
362 self.stop_timer();
363 let elapsed = self.start_time.elapsed();
364 let duration = format_duration(elapsed.as_secs());
365 let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
366 eprintln!("{GREEN}{} completed in {}{RESET}", prefix, duration);
367 }
368
369 pub fn finish_error(&mut self, error: &str) {
370 self.stop_timer();
371 let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
372 eprintln!("{RED}{} failed: {}{RESET}", prefix, error);
373 }
374
375 pub fn elapsed_secs(&self) -> u64 {
376 self.start_time.elapsed().as_secs()
377 }
378}
379
380impl AgentDisplay for VerboseTimer {
381 fn start(&mut self) {
382 self.started = true;
385 }
386
387 fn update(&mut self, _activity: &str) {
388 }
391
392 fn finish_success(&mut self) {
393 VerboseTimer::finish_success(self);
395 }
396
397 fn finish_error(&mut self, error: &str) {
398 VerboseTimer::finish_error(self, error);
400 }
401
402 fn finish_with_outcome(&mut self, outcome: Outcome) {
403 self.stop_timer();
404 let elapsed = self.start_time.elapsed();
405 let duration = format_duration(elapsed.as_secs());
406 let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
407
408 let token_suffix = outcome
410 .tokens
411 .map(|t| format!(" - {} tokens", format_tokens(t)))
412 .unwrap_or_default();
413
414 if outcome.success {
415 eprintln!(
416 "{GREEN}\u{2714} {} completed in {} - {}{}{RESET}",
417 prefix, duration, outcome.message, token_suffix
418 );
419 } else {
420 eprintln!(
421 "{RED}\u{2718} {} failed in {} - {}{}{RESET}",
422 prefix, duration, outcome.message, token_suffix
423 );
424 }
425 }
426
427 fn agent_name(&self) -> &str {
428 &self.story_id
429 }
430
431 fn elapsed_secs(&self) -> u64 {
432 VerboseTimer::elapsed_secs(self)
433 }
434
435 fn iteration_info(&self) -> Option<&IterationInfo> {
436 self.iteration_info.as_ref()
437 }
438
439 fn set_iteration_info(&mut self, info: IterationInfo) {
440 self.iteration_info = Some(info);
441 }
442}
443
444impl Drop for VerboseTimer {
445 fn drop(&mut self) {
446 self.stop_flag.store(true, Ordering::Relaxed);
448 if let Some(handle) = self.timer_thread.take() {
450 let _ = handle.join();
451 }
452 }
453}
454
455pub fn format_duration(secs: u64) -> String {
461 if secs < 60 {
462 format!("{}s", secs)
463 } else {
464 let mins = secs / 60;
465 let remaining_secs = secs % 60;
466 format!("{}m {}s", mins, remaining_secs)
467 }
468}
469
470pub fn format_tokens(tokens: u64) -> String {
472 let s = tokens.to_string();
473 let mut result = String::with_capacity(s.len() + s.len() / 3);
474 let chars: Vec<char> = s.chars().collect();
475 for (i, c) in chars.iter().enumerate() {
476 if i > 0 && (chars.len() - i).is_multiple_of(3) {
477 result.push(',');
478 }
479 result.push(*c);
480 }
481 result
482}
483
484fn format_display_prefix(story_id: &str, iteration_info: &Option<IterationInfo>) -> String {
487 if let Some(info) = iteration_info {
488 if let Some(formatted) = info.format() {
489 return formatted;
490 }
491 }
492 if story_id == "Spec" {
495 "Spec generation".to_string()
496 } else {
497 story_id.to_string()
498 }
499}
500
501fn get_terminal_width() -> usize {
507 terminal_size()
508 .map(|(Width(w), _)| w as usize)
509 .unwrap_or(DEFAULT_TERMINAL_WIDTH as usize)
510}
511
512pub struct ClaudeSpinner {
513 spinner: Arc<ProgressBar>,
514 story_id: String,
515 stop_flag: Arc<AtomicBool>,
516 timer_thread: Option<JoinHandle<()>>,
517 start_time: Instant,
518 last_activity: Arc<std::sync::Mutex<String>>,
519 iteration_info: Option<IterationInfo>,
520 iteration_info_shared: Arc<std::sync::Mutex<Option<IterationInfo>>>,
521}
522
523impl ClaudeSpinner {
524 pub fn new(story_id: &str) -> Self {
525 Self::create(story_id, format!("{} | Starting...", story_id))
526 }
527
528 pub fn new_with_story_progress(story_id: &str, current: u32, total: u32) -> Self {
531 let info = IterationInfo::with_phase(story_id, current, total);
532 let prefix = info.format().unwrap_or_else(|| story_id.to_string());
533 Self::create_with_iteration(story_id, format!("{} | Starting...", prefix), Some(info))
534 }
535
536 pub fn new_for_review(current: u32, total: u32) -> Self {
539 let info = IterationInfo::with_phase("Review", current, total);
540 let prefix = info.format().unwrap_or_else(|| "Review".to_string());
541 Self::create_with_iteration("Review", format!("{} | Starting...", prefix), Some(info))
542 }
543
544 pub fn new_for_correct(current: u32, total: u32) -> Self {
547 let info = IterationInfo::with_phase("Correct", current, total);
548 let prefix = info.format().unwrap_or_else(|| "Correct".to_string());
549 Self::create_with_iteration("Correct", format!("{} | Starting...", prefix), Some(info))
550 }
551
552 pub fn new_for_spec() -> Self {
553 Self::create("Spec", "Spec generation | Starting...".to_string())
554 }
555
556 pub fn new_for_commit() -> Self {
559 let info = IterationInfo::phase_only("Commit");
560 let prefix = info.format().unwrap_or_else(|| "Commit".to_string());
561 Self::create_with_iteration("Commit", format!("{} | Starting...", prefix), Some(info))
562 }
563
564 fn create(story_id: &str, initial_message: String) -> Self {
565 Self::create_with_iteration(story_id, initial_message, None)
566 }
567
568 fn create_with_iteration(
569 story_id: &str,
570 initial_message: String,
571 iteration_info: Option<IterationInfo>,
572 ) -> Self {
573 let spinner = Arc::new(ProgressBar::new_spinner());
574 spinner.set_style(
575 ProgressStyle::default_spinner()
576 .tick_chars(SPINNER_CHARS)
577 .template("{spinner:.cyan} Claude working on {msg}")
578 .expect("invalid template"),
579 );
580 spinner.set_message(format!("{} [00:00:00]", initial_message));
581 spinner.enable_steady_tick(Duration::from_millis(80));
582
583 let stop_flag = Arc::new(AtomicBool::new(false));
584 let start_time = Instant::now();
585 let last_activity = Arc::new(std::sync::Mutex::new("Starting...".to_string()));
586 let iteration_info_shared = Arc::new(std::sync::Mutex::new(iteration_info.clone()));
587
588 let spinner_clone = Arc::clone(&spinner);
590 let stop_flag_clone = Arc::clone(&stop_flag);
591 let last_activity_clone = Arc::clone(&last_activity);
592 let iteration_info_clone = Arc::clone(&iteration_info_shared);
593 let story_id_owned = story_id.to_string();
594
595 let timer_thread = thread::spawn(move || {
597 while !stop_flag_clone.load(Ordering::Relaxed) {
598 thread::sleep(Duration::from_secs(1));
599
600 if stop_flag_clone.load(Ordering::Relaxed) {
602 break;
603 }
604
605 let elapsed = start_time.elapsed();
606 let hours = elapsed.as_secs() / 3600;
607 let mins = (elapsed.as_secs() % 3600) / 60;
608 let secs = elapsed.as_secs() % 60;
609 let time_str = format!("{:02}:{:02}:{:02}", hours, mins, secs);
610
611 let activity = last_activity_clone.lock().unwrap().clone();
612 let iter_info = iteration_info_clone.lock().unwrap().clone();
613 let prefix = format_display_prefix(&story_id_owned, &iter_info);
614 let fixed_activity = fixed_width_activity(&activity);
615
616 spinner_clone
617 .set_message(format!("{} | {} [{}]", prefix, fixed_activity, time_str));
618 }
619 });
620
621 Self {
622 spinner,
623 story_id: story_id.to_string(),
624 stop_flag,
625 timer_thread: Some(timer_thread),
626 start_time,
627 last_activity,
628 iteration_info,
629 iteration_info_shared,
630 }
631 }
632
633 pub fn update(&self, activity: &str) {
634 if let Ok(mut guard) = self.last_activity.lock() {
636 *guard = activity.to_string();
637 }
638
639 let elapsed = self.start_time.elapsed();
641 let hours = elapsed.as_secs() / 3600;
642 let mins = (elapsed.as_secs() % 3600) / 60;
643 let secs = elapsed.as_secs() % 60;
644 let time_str = format!("{:02}:{:02}:{:02}", hours, mins, secs);
645
646 let iter_info = self.iteration_info_shared.lock().unwrap().clone();
647 let prefix = format_display_prefix(&self.story_id, &iter_info);
648 let fixed_activity = fixed_width_activity(activity);
649
650 self.spinner
651 .set_message(format!("{} | {} [{}]", prefix, fixed_activity, time_str));
652 }
653
654 fn stop_timer(&mut self) {
655 self.stop_flag.store(true, Ordering::Relaxed);
656 if let Some(handle) = self.timer_thread.take() {
657 let _ = handle.join();
659 }
660 }
661
662 pub fn clear(&self) {
665 self.spinner.finish_and_clear();
666 }
667
668 pub fn finish_success(&mut self, duration_secs: u64) {
669 self.stop_timer();
670 let duration = format_duration(duration_secs);
671 let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
672 self.spinner.finish_and_clear();
674 println!(
675 "{GREEN}\u{2714} {} completed in {}{RESET}",
676 prefix, duration
677 );
678 }
679
680 pub fn finish_error(&mut self, error: &str) {
681 self.stop_timer();
682 let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
683 let available = get_terminal_width().saturating_sub(prefix.chars().count() + 15);
685 let truncated = truncate_activity(error, available.max(20));
686 self.spinner.finish_and_clear();
688 println!("{RED}\u{2718} {} failed: {}{RESET}", prefix, truncated);
689 }
690
691 pub fn finish_with_message(&mut self, message: &str) {
692 self.stop_timer();
693 let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
694 self.spinner.finish_and_clear();
696 println!("{GREEN}\u{2714} {}: {}{RESET}", prefix, message);
697 }
698
699 pub fn elapsed_secs(&self) -> u64 {
700 self.start_time.elapsed().as_secs()
701 }
702}
703
704impl AgentDisplay for ClaudeSpinner {
705 fn start(&mut self) {
706 }
708
709 fn update(&mut self, activity: &str) {
710 ClaudeSpinner::update(self, activity);
712 }
713
714 fn finish_success(&mut self) {
715 let elapsed = self.start_time.elapsed().as_secs();
717 ClaudeSpinner::finish_success(self, elapsed);
718 }
719
720 fn finish_error(&mut self, error: &str) {
721 ClaudeSpinner::finish_error(self, error);
723 }
724
725 fn finish_with_outcome(&mut self, outcome: Outcome) {
726 self.stop_timer();
727 let elapsed = self.start_time.elapsed();
728 let duration = format_duration(elapsed.as_secs());
729 let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
730 self.spinner.finish_and_clear();
731
732 let token_suffix = outcome
734 .tokens
735 .map(|t| format!(" - {} tokens", format_tokens(t)))
736 .unwrap_or_default();
737
738 if outcome.success {
739 println!(
740 "{GREEN}\u{2714} {} completed in {} - {}{}{RESET}",
741 prefix, duration, outcome.message, token_suffix
742 );
743 } else {
744 println!(
745 "{RED}\u{2718} {} failed in {} - {}{}{RESET}",
746 prefix, duration, outcome.message, token_suffix
747 );
748 }
749 }
750
751 fn agent_name(&self) -> &str {
752 &self.story_id
753 }
754
755 fn elapsed_secs(&self) -> u64 {
756 ClaudeSpinner::elapsed_secs(self)
757 }
758
759 fn iteration_info(&self) -> Option<&IterationInfo> {
760 self.iteration_info.as_ref()
761 }
762
763 fn set_iteration_info(&mut self, info: IterationInfo) {
764 self.iteration_info = Some(info.clone());
765 if let Ok(mut guard) = self.iteration_info_shared.lock() {
767 *guard = Some(info);
768 }
769 }
770}
771
772impl Drop for ClaudeSpinner {
773 fn drop(&mut self) {
774 self.stop_flag.store(true, Ordering::Relaxed);
776 if let Some(handle) = self.timer_thread.take() {
777 let _ = handle.join();
778 }
779 self.spinner.finish_and_clear();
782 }
783}
784
785fn truncate_activity(activity: &str, max_len: usize) -> String {
786 let first_line = activity.lines().next().unwrap_or(activity);
788 let cleaned = first_line.trim();
789
790 let char_count = cleaned.chars().count();
792 if char_count <= max_len {
793 cleaned.to_string()
794 } else {
795 if max_len < 4 {
797 "...".to_string()
798 } else {
799 let truncated: String = cleaned.chars().take(max_len - 3).collect();
800 format!("{}...", truncated)
801 }
802 }
803}
804
805fn fixed_width_activity(activity: &str) -> String {
811 let first_line = activity.lines().next().unwrap_or(activity);
813 let cleaned = first_line.trim();
814
815 let char_count = cleaned.chars().count();
816
817 if char_count > ACTIVITY_TEXT_WIDTH {
818 let truncated: String = cleaned.chars().take(ACTIVITY_TEXT_WIDTH - 3).collect();
820 format!("{}...", truncated)
821 } else {
822 format!("{:width$}", cleaned, width = ACTIVITY_TEXT_WIDTH)
824 }
825}
826
827#[derive(Debug, Clone, PartialEq)]
833pub enum BreadcrumbState {
834 Story,
835 Review,
836 Correct,
837 Commit,
838}
839
840impl BreadcrumbState {
841 pub fn display_name(&self) -> &'static str {
843 match self {
844 BreadcrumbState::Story => "Story",
845 BreadcrumbState::Review => "Review",
846 BreadcrumbState::Correct => "Correct",
847 BreadcrumbState::Commit => "Commit",
848 }
849 }
850}
851
852#[derive(Debug, Clone, Default)]
861pub struct Breadcrumb {
862 completed: Vec<BreadcrumbState>,
864 current: Option<BreadcrumbState>,
866}
867
868impl Breadcrumb {
869 pub fn new() -> Self {
871 Self {
872 completed: Vec::new(),
873 current: None,
874 }
875 }
876
877 pub fn reset(&mut self) {
879 self.completed.clear();
880 self.current = None;
881 }
882
883 pub fn enter_state(&mut self, state: BreadcrumbState) {
885 if let Some(current) = self.current.take() {
887 self.completed.push(current);
888 }
889 self.current = Some(state);
890 }
891
892 pub fn complete_current(&mut self) {
894 if let Some(current) = self.current.take() {
895 self.completed.push(current);
896 }
897 }
898
899 pub fn completed_states(&self) -> &[BreadcrumbState] {
901 &self.completed
902 }
903
904 pub fn current_state(&self) -> Option<&BreadcrumbState> {
906 self.current.as_ref()
907 }
908
909 pub fn is_empty(&self) -> bool {
911 self.completed.is_empty() && self.current.is_none()
912 }
913
914 pub fn render(&self, max_width: Option<usize>) -> String {
922 if self.is_empty() {
923 return String::new();
924 }
925
926 let max_width = max_width.unwrap_or_else(get_terminal_width);
927 let separator = format!("{GRAY} → {RESET}");
928 let prefix = format!("{DIM}Journey:{RESET} ");
929
930 let mut parts: Vec<String> = Vec::new();
932
933 for state in &self.completed {
935 parts.push(format!("{GREEN}{}{RESET}", state.display_name()));
936 }
937
938 if let Some(current) = &self.current {
940 parts.push(format!("{YELLOW}{}{RESET}", current.display_name()));
941 }
942
943 let plain_prefix = "Journey: ";
945 let plain_separator = " → ";
946 let plain_parts: Vec<&str> = self
947 .completed
948 .iter()
949 .map(|s| s.display_name())
950 .chain(self.current.iter().map(|s| s.display_name()))
951 .collect();
952 let plain_trail = plain_parts.join(plain_separator);
953 let plain_full = format!("{}{}", plain_prefix, plain_trail);
954 let plain_len = plain_full.chars().count();
955
956 if plain_len <= max_width {
958 return format!("{}{}", prefix, parts.join(&separator));
959 }
960
961 let ellipsis = "...";
963 let available = max_width.saturating_sub(plain_prefix.len() + ellipsis.len() + 4); let mut fit_parts: Vec<String> = Vec::new();
967 let mut fit_plain_parts: Vec<&str> = Vec::new();
968 let mut current_len: usize = 0;
969
970 if let Some(current) = &self.current {
972 fit_parts.push(format!("{YELLOW}{}{RESET}", current.display_name()));
973 fit_plain_parts.push(current.display_name());
974 current_len = current.display_name().chars().count();
975 }
976
977 for state in self.completed.iter().rev() {
979 let state_len = state.display_name().chars().count();
980 let sep_len = if fit_parts.is_empty() {
981 0
982 } else {
983 plain_separator.len()
984 };
985
986 if current_len + sep_len + state_len <= available {
987 fit_parts.insert(0, format!("{GREEN}{}{RESET}", state.display_name()));
988 fit_plain_parts.insert(0, state.display_name());
989 current_len += sep_len + state_len;
990 } else {
991 break;
992 }
993 }
994
995 if fit_plain_parts.len() < plain_parts.len() {
997 format!(
998 "{}{DIM}...{RESET}{}{}",
999 prefix,
1000 separator,
1001 fit_parts.join(&separator)
1002 )
1003 } else {
1004 format!("{}{}", prefix, fit_parts.join(&separator))
1005 }
1006 }
1007
1008 pub fn print(&self) {
1010 if !self.is_empty() {
1011 println!("{}", self.render(None));
1012 }
1013 }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019
1020 #[test]
1025 fn test_truncate_activity() {
1026 assert_eq!(truncate_activity("Short message", 50), "Short message");
1028
1029 let long_msg = "This is a very long message that should be truncated";
1031 let result = truncate_activity(long_msg, 30);
1032 assert_eq!(result.chars().count(), 30);
1033 assert!(result.ends_with("..."));
1034
1035 assert_eq!(
1037 truncate_activity("First line\nSecond line", 50),
1038 "First line"
1039 );
1040
1041 let utf8_msg = "Implementing 日本語 feature with more text here";
1043 let result = truncate_activity(utf8_msg, 20);
1044 assert_eq!(result.chars().count(), 20);
1045 }
1046
1047 #[test]
1048 fn test_fixed_width_activity() {
1049 let result = fixed_width_activity("Working");
1051 assert_eq!(result.chars().count(), ACTIVITY_TEXT_WIDTH);
1052 assert!(result.starts_with("Working"));
1053
1054 let long_msg = "This is a very long message that exceeds forty characters limit";
1056 let result = fixed_width_activity(long_msg);
1057 assert_eq!(result.chars().count(), ACTIVITY_TEXT_WIDTH);
1058 assert!(result.ends_with("..."));
1059
1060 let result = fixed_width_activity("");
1062 assert_eq!(result.chars().count(), ACTIVITY_TEXT_WIDTH);
1063 assert!(result.chars().all(|c| c == ' '));
1064 }
1065
1066 #[test]
1071 fn test_format_duration() {
1072 assert_eq!(format_duration(0), "0s");
1073 assert_eq!(format_duration(59), "59s");
1074 assert_eq!(format_duration(60), "1m 0s");
1075 assert_eq!(format_duration(125), "2m 5s");
1076 }
1077
1078 #[test]
1083 fn test_iteration_info_format() {
1084 let info = IterationInfo::with_phase("Review", 1, 3);
1086 assert_eq!(info.format(), Some("[Review 1/3]".to_string()));
1087
1088 let info = IterationInfo::phase_only("Commit");
1090 assert_eq!(info.format(), Some("[Commit]".to_string()));
1091
1092 let info = IterationInfo::new(2, 5);
1094 assert_eq!(info.format(), Some("[2/5]".to_string()));
1095
1096 assert_eq!(IterationInfo::default().format(), None);
1098 }
1099
1100 #[test]
1105 fn test_format_display_prefix() {
1106 let info = IterationInfo::with_phase("Review", 1, 3);
1108 assert_eq!(format_display_prefix("Review", &Some(info)), "[Review 1/3]");
1109
1110 assert_eq!(format_display_prefix("US-001", &None), "US-001");
1112
1113 assert_eq!(format_display_prefix("Spec", &None), "Spec generation");
1115 }
1116
1117 #[test]
1122 fn test_progress_context_dual_context() {
1123 let ctx = ProgressContext::new("US-001", 2, 5);
1124
1125 let iter_info = Some(IterationInfo::with_phase("Review", 1, 3));
1127 assert_eq!(
1128 ctx.format_dual_context(&iter_info),
1129 Some("[US-001 2/5 | Review 1/3]".to_string())
1130 );
1131
1132 assert_eq!(
1134 ctx.format_dual_context(&None),
1135 Some("[US-001 2/5]".to_string())
1136 );
1137
1138 let empty_ctx = ProgressContext::default();
1140 assert_eq!(empty_ctx.format_dual_context(&None), None);
1141 }
1142
1143 #[test]
1148 fn test_breadcrumb_workflow() {
1149 let mut breadcrumb = Breadcrumb::new();
1150 assert!(breadcrumb.is_empty());
1151
1152 breadcrumb.enter_state(BreadcrumbState::Story);
1154 assert_eq!(breadcrumb.current_state(), Some(&BreadcrumbState::Story));
1155 assert!(breadcrumb.completed_states().is_empty());
1156
1157 breadcrumb.enter_state(BreadcrumbState::Review);
1158 assert_eq!(breadcrumb.current_state(), Some(&BreadcrumbState::Review));
1159 assert_eq!(breadcrumb.completed_states(), &[BreadcrumbState::Story]);
1160
1161 breadcrumb.reset();
1163 assert!(breadcrumb.is_empty());
1164 }
1165
1166 #[test]
1167 fn test_breadcrumb_render() {
1168 let mut breadcrumb = Breadcrumb::new();
1169 breadcrumb.enter_state(BreadcrumbState::Story);
1170 breadcrumb.enter_state(BreadcrumbState::Review);
1171
1172 let rendered = breadcrumb.render(Some(100));
1173 assert!(rendered.contains("Journey:"));
1174 assert!(rendered.contains("Story"));
1175 assert!(rendered.contains("Review"));
1176 assert!(rendered.contains("→"));
1177 }
1178
1179 #[test]
1184 fn test_spinner_lifecycle() {
1185 let mut spinner = ClaudeSpinner::new("US-001");
1186 assert!(!spinner.stop_flag.load(Ordering::Relaxed));
1187
1188 spinner.update("Working");
1189 let activity = spinner.last_activity.lock().unwrap().clone();
1190 assert_eq!(activity, "Working");
1191
1192 spinner.stop_timer();
1193 assert!(spinner.stop_flag.load(Ordering::Relaxed));
1194 }
1195
1196 #[test]
1197 fn test_verbose_timer_lifecycle() {
1198 let mut timer = VerboseTimer::new("US-001");
1199 assert!(!timer.stop_flag.load(Ordering::Relaxed));
1200
1201 timer.stop_timer();
1202 assert!(timer.stop_flag.load(Ordering::Relaxed));
1203 }
1204
1205 #[test]
1210 fn test_drop_stops_timer() {
1211 let stop_flag_clone;
1212 {
1213 let spinner = ClaudeSpinner::new("test");
1214 stop_flag_clone = Arc::clone(&spinner.stop_flag);
1215 assert!(!stop_flag_clone.load(Ordering::Relaxed));
1216 }
1217 assert!(stop_flag_clone.load(Ordering::Relaxed));
1218 }
1219
1220 #[test]
1225 fn test_format_tokens_zero() {
1226 assert_eq!(format_tokens(0), "0");
1227 }
1228
1229 #[test]
1230 fn test_format_tokens_small() {
1231 assert_eq!(format_tokens(1), "1");
1232 assert_eq!(format_tokens(12), "12");
1233 assert_eq!(format_tokens(123), "123");
1234 }
1235
1236 #[test]
1237 fn test_format_tokens_thousands() {
1238 assert_eq!(format_tokens(1000), "1,000");
1239 assert_eq!(format_tokens(1234), "1,234");
1240 assert_eq!(format_tokens(12345), "12,345");
1241 assert_eq!(format_tokens(123456), "123,456");
1242 }
1243
1244 #[test]
1245 fn test_format_tokens_millions() {
1246 assert_eq!(format_tokens(1000000), "1,000,000");
1247 assert_eq!(format_tokens(1234567), "1,234,567");
1248 assert_eq!(format_tokens(12345678), "12,345,678");
1249 }
1250
1251 #[test]
1252 fn test_format_tokens_large() {
1253 assert_eq!(format_tokens(123456789), "123,456,789");
1254 assert_eq!(format_tokens(1234567890), "1,234,567,890");
1255 }
1256
1257 #[test]
1258 fn test_format_tokens_boundary_cases() {
1259 assert_eq!(format_tokens(999), "999");
1260 assert_eq!(format_tokens(1000), "1,000");
1261 assert_eq!(format_tokens(9999), "9,999");
1262 assert_eq!(format_tokens(10000), "10,000");
1263 assert_eq!(format_tokens(99999), "99,999");
1264 assert_eq!(format_tokens(100000), "100,000");
1265 }
1266
1267 #[test]
1268 fn test_outcome_with_tokens() {
1269 let outcome = Outcome::success("Done").with_tokens(45678);
1270 assert!(outcome.success);
1271 assert_eq!(outcome.message, "Done");
1272 assert_eq!(outcome.tokens, Some(45678));
1273 }
1274
1275 #[test]
1276 fn test_outcome_with_optional_tokens_some() {
1277 let outcome = Outcome::success("Done").with_optional_tokens(Some(12345));
1278 assert_eq!(outcome.tokens, Some(12345));
1279 }
1280
1281 #[test]
1282 fn test_outcome_with_optional_tokens_none() {
1283 let outcome = Outcome::success("Done").with_optional_tokens(None);
1284 assert_eq!(outcome.tokens, None);
1285 }
1286
1287 #[test]
1288 fn test_outcome_default_no_tokens() {
1289 let outcome = Outcome::success("Done");
1290 assert_eq!(outcome.tokens, None);
1291
1292 let outcome_fail = Outcome::failure("Error");
1293 assert_eq!(outcome_fail.tokens, None);
1294 }
1295
1296 #[test]
1297 fn test_outcome_failure_with_tokens() {
1298 let outcome = Outcome::failure("Build failed").with_tokens(1000);
1299 assert!(!outcome.success);
1300 assert_eq!(outcome.message, "Build failed");
1301 assert_eq!(outcome.tokens, Some(1000));
1302 }
1303}