Skip to main content

autom8/
progress.rs

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;
11/// Fixed width for activity text to prevent timer position jumping (US-002)
12pub const ACTIVITY_TEXT_WIDTH: usize = 40;
13
14// ============================================================================
15// AgentDisplay: Unified trait/interface for all agent displays
16// ============================================================================
17
18/// Information about the current iteration or progress context
19#[derive(Debug, Clone, Default)]
20pub struct IterationInfo {
21    /// Current iteration number (1-indexed)
22    pub current: Option<u32>,
23    /// Total number of iterations (if known)
24    pub total: Option<u32>,
25    /// Phase identifier (e.g., "Review", "Correct", "Commit")
26    pub phase: Option<String>,
27}
28
29// ============================================================================
30// ProgressContext: Overall progress context for unified display (US-010)
31// ============================================================================
32
33/// Overall progress context holding story progress and current phase information.
34///
35/// This struct tracks the current story position within the total stories,
36/// and can be combined with iteration info for dual-context display.
37///
38/// # Display Format
39/// During review/correct, shows both story progress and iteration:
40/// `[US-001 2/5 | Review 1/3]`
41#[derive(Debug, Clone, Default)]
42pub struct ProgressContext {
43    /// Current story index (1-indexed)
44    pub story_index: Option<u32>,
45    /// Total number of stories
46    pub total_stories: Option<u32>,
47    /// Current story ID (e.g., "US-001")
48    pub story_id: Option<String>,
49    /// Current phase name
50    pub current_phase: Option<String>,
51}
52
53impl ProgressContext {
54    /// Create a new ProgressContext with story progress
55    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    /// Create a ProgressContext with phase information
65    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    /// Set the current phase
75    pub fn set_phase(&mut self, phase: &str) {
76        self.current_phase = Some(phase.to_string());
77    }
78
79    /// Format the story progress part: `[US-001 2/5]`
80    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    /// Format a dual-context display combining story progress and iteration info.
88    ///
89    /// Returns format like `[US-001 2/5 | Review 1/3]` when both contexts are present,
90    /// or just the story progress or iteration info if only one is available.
91    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                // Remove brackets and combine: "[US-001 2/5]" + "[Review 1/3]" -> "[US-001 2/5 | Review 1/3]"
98                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    /// Create a new IterationInfo with current and total iteration counts
111    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    /// Create a new IterationInfo with phase and iteration counts
120    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    /// Create an IterationInfo with just a phase name (no iteration counts)
129    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    /// Format the iteration info as a string for display
138    /// Returns format like "[Review 1/3]" or "[Commit]" or "[2/5]"
139    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/// Outcome information for agent completion
150#[derive(Debug, Clone)]
151pub struct Outcome {
152    /// Whether the operation was successful
153    pub success: bool,
154    /// Brief description of the outcome (e.g., "3 issues found", "abc1234")
155    pub message: String,
156    /// Optional token count to display (always shown if present, not gated by verbose)
157    pub tokens: Option<u64>,
158}
159
160impl Outcome {
161    /// Create a successful outcome with a message
162    pub fn success(message: impl Into<String>) -> Self {
163        Self {
164            success: true,
165            message: message.into(),
166            tokens: None,
167        }
168    }
169
170    /// Create a failed outcome with an error message
171    pub fn failure(message: impl Into<String>) -> Self {
172        Self {
173            success: false,
174            message: message.into(),
175            tokens: None,
176        }
177    }
178
179    /// Add token count to this outcome
180    pub fn with_tokens(mut self, tokens: u64) -> Self {
181        self.tokens = Some(tokens);
182        self
183    }
184
185    /// Add optional token count to this outcome (no-op if None)
186    pub fn with_optional_tokens(mut self, tokens: Option<u64>) -> Self {
187        self.tokens = tokens;
188        self
189    }
190}
191
192/// Common interface for agent display components.
193///
194/// This trait defines a unified contract for how all agents report their status,
195/// ensuring consistent display across Runner, Reviewer, Corrector, and Commit phases.
196///
197/// All implementors should provide:
198/// - Agent name identification
199/// - Elapsed time tracking
200/// - Activity preview updates
201/// - Iteration/progress context
202pub trait AgentDisplay {
203    /// Start the display for an agent operation.
204    /// Called when the agent begins its work.
205    fn start(&mut self);
206
207    /// Update the display with current activity information.
208    ///
209    /// # Arguments
210    /// * `activity` - Brief description of current activity (will be truncated if too long)
211    fn update(&mut self, activity: &str);
212
213    /// Mark the operation as successfully completed.
214    /// Stops any timers and displays a success message.
215    fn finish_success(&mut self);
216
217    /// Mark the operation as failed.
218    /// Stops any timers and displays an error message.
219    ///
220    /// # Arguments
221    /// * `error` - Description of what went wrong
222    fn finish_error(&mut self, error: &str);
223
224    /// Mark the operation as completed with a specific outcome.
225    /// Allows for more detailed completion information than simple success/failure.
226    ///
227    /// # Arguments
228    /// * `outcome` - The outcome information including success status and message
229    fn finish_with_outcome(&mut self, outcome: Outcome);
230
231    /// Get the agent's display name
232    fn agent_name(&self) -> &str;
233
234    /// Get the elapsed time in seconds since the operation started
235    fn elapsed_secs(&self) -> u64;
236
237    /// Get the current iteration information, if any
238    fn iteration_info(&self) -> Option<&IterationInfo>;
239
240    /// Set the iteration information for progress context
241    fn set_iteration_info(&mut self, info: IterationInfo);
242}
243
244/// Extension trait for using AgentDisplay in a type-erased context
245pub trait AgentDisplayExt: AgentDisplay {
246    /// Finish with success (type-erased version that can be called on &mut dyn AgentDisplay)
247    fn complete_success(&mut self) {
248        AgentDisplay::finish_success(self);
249    }
250
251    /// Finish with error (type-erased version)
252    fn complete_error(&mut self, error: &str) {
253        AgentDisplay::finish_error(self, error);
254    }
255
256    /// Finish with outcome (type-erased version)
257    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
264// ============================================================================
265// VerboseTimer: Shows elapsed time in verbose mode without truncating output
266// ============================================================================
267
268/// Timer for verbose mode that periodically prints a status line
269/// while allowing full Claude output to scroll without truncation.
270pub 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        // Spawn timer thread that prints status every 10 seconds
288        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                // Print status every 10 seconds
298                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    /// Create a new timer for a story with iteration context
323    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    /// Create a new timer for review with iteration context
330    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    /// Create a new timer for correction with iteration context
337    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    /// Create a new timer for commit
348    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        // VerboseTimer starts automatically on creation, so this is a no-op
383        // but we mark it as started for consistency
384        self.started = true;
385    }
386
387    fn update(&mut self, _activity: &str) {
388        // VerboseTimer doesn't truncate output, so update is a no-op
389        // The full output scrolls naturally in verbose mode
390    }
391
392    fn finish_success(&mut self) {
393        // Delegate to the inherent method
394        VerboseTimer::finish_success(self);
395    }
396
397    fn finish_error(&mut self, error: &str) {
398        // Delegate to the inherent method
399        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        // Build the token suffix if tokens are present
409        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        // Ensure timer thread is stopped when VerboseTimer is dropped
447        self.stop_flag.store(true, Ordering::Relaxed);
448        // Wait for thread to finish to prevent partial lines on screen
449        if let Some(handle) = self.timer_thread.take() {
450            let _ = handle.join();
451        }
452    }
453}
454
455// ============================================================================
456// Helper functions
457// ============================================================================
458
459/// Format duration in a human-readable way: "Xs" for <60s, "Xm Ys" for >=60s
460pub 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
470/// Format token count with thousands separators (e.g., 1,234,567)
471pub 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
484/// Format the display prefix based on story_id and iteration info
485/// Returns format like "[US-001 2/5]", "[Review 1/3]", "[Commit]", or just the story_id
486fn 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    // Fall back to story_id if no iteration info or invalid format
493    // Special case for Spec
494    if story_id == "Spec" {
495        "Spec generation".to_string()
496    } else {
497        story_id.to_string()
498    }
499}
500
501// ============================================================================
502// ClaudeSpinner: Single-line preview mode with spinner animation
503// ============================================================================
504
505/// Get the current terminal width, falling back to a default if unavailable
506fn 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    /// Create a new spinner for a story with iteration context
529    /// Display format: `[US-001 2/5] | activity [HH:MM:SS]`
530    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    /// Create a new spinner for review with iteration context
537    /// Display format: `[Review 1/3] | activity [HH:MM:SS]`
538    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    /// Create a new spinner for correction with iteration context
545    /// Display format: `[Correct 1/3] | activity [HH:MM:SS]`
546    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    /// Create a new spinner for commit
557    /// Display format: `[Commit] | activity [HH:MM:SS]`
558    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        // Clone for timer thread
589        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        // Spawn independent timer thread that updates every second
596        let timer_thread = thread::spawn(move || {
597            while !stop_flag_clone.load(Ordering::Relaxed) {
598                thread::sleep(Duration::from_secs(1));
599
600                // Check again after sleep in case we should stop
601                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        // Update the last activity for the timer thread to use
635        if let Ok(mut guard) = self.last_activity.lock() {
636            *guard = activity.to_string();
637        }
638
639        // Also update immediately for responsiveness
640        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            // Wait for thread to finish (it should exit quickly)
658            let _ = handle.join();
659        }
660    }
661
662    /// Clear the spinner line without printing a final message.
663    /// Used to ensure no visual artifacts remain before printing completion output.
664    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        // Clear the line first, then print completion message to ensure clean output
673        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        // For error messages, use a reasonable width accounting for " failed: " and color codes
684        let available = get_terminal_width().saturating_sub(prefix.chars().count() + 15);
685        let truncated = truncate_activity(error, available.max(20));
686        // Clear the line first, then print error message to ensure clean output
687        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        // Clear the line first, then print message to ensure clean output
695        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        // ClaudeSpinner starts automatically on creation, so this is a no-op
707    }
708
709    fn update(&mut self, activity: &str) {
710        // Delegate to the inherent method (note: takes &self, not &mut self)
711        ClaudeSpinner::update(self, activity);
712    }
713
714    fn finish_success(&mut self) {
715        // Use internal elapsed time for trait implementation
716        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        // Delegate to the inherent method
722        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        // Build the token suffix if tokens are present
733        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        // Also update the shared version for the timer thread
766        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        // Ensure timer thread is stopped and spinner is cleared when dropped
775        self.stop_flag.store(true, Ordering::Relaxed);
776        if let Some(handle) = self.timer_thread.take() {
777            let _ = handle.join();
778        }
779        // Clear the spinner line if it hasn't been finished yet
780        // This prevents partial lines from remaining on screen
781        self.spinner.finish_and_clear();
782    }
783}
784
785fn truncate_activity(activity: &str, max_len: usize) -> String {
786    // Take first line only and clean it up
787    let first_line = activity.lines().next().unwrap_or(activity);
788    let cleaned = first_line.trim();
789
790    // Count characters (not bytes) to handle UTF-8 properly
791    let char_count = cleaned.chars().count();
792    if char_count <= max_len {
793        cleaned.to_string()
794    } else {
795        // Need at least 4 chars for "X..." where X is at least one character
796        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
805/// Create a fixed-width activity text string (US-002).
806///
807/// - Truncates text longer than ACTIVITY_TEXT_WIDTH with "..." suffix
808/// - Pads text shorter than ACTIVITY_TEXT_WIDTH with trailing spaces
809/// - This ensures the timer position remains fixed regardless of activity text content
810fn fixed_width_activity(activity: &str) -> String {
811    // Take first line only and clean it up
812    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        // Truncate with "..." suffix
819        let truncated: String = cleaned.chars().take(ACTIVITY_TEXT_WIDTH - 3).collect();
820        format!("{}...", truncated)
821    } else {
822        // Pad with spaces to reach fixed width
823        format!("{:width$}", cleaned, width = ACTIVITY_TEXT_WIDTH)
824    }
825}
826
827// ============================================================================
828// Breadcrumb: Track workflow journey through states
829// ============================================================================
830
831/// Represents a single state in the workflow journey
832#[derive(Debug, Clone, PartialEq)]
833pub enum BreadcrumbState {
834    Story,
835    Review,
836    Correct,
837    Commit,
838}
839
840impl BreadcrumbState {
841    /// Get the display name for this state
842    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/// Tracks the workflow journey through different states.
853///
854/// Displays a breadcrumb trail showing completed and current states:
855/// `Journey: Story → Review → Correct → Review`
856///
857/// - Completed states are shown in green
858/// - Current state is shown in yellow
859/// - Future states are not shown
860#[derive(Debug, Clone, Default)]
861pub struct Breadcrumb {
862    /// History of states visited (completed states)
863    completed: Vec<BreadcrumbState>,
864    /// Current state being processed (if any)
865    current: Option<BreadcrumbState>,
866}
867
868impl Breadcrumb {
869    /// Create a new empty breadcrumb trail
870    pub fn new() -> Self {
871        Self {
872            completed: Vec::new(),
873            current: None,
874        }
875    }
876
877    /// Reset the breadcrumb trail (used at start of each new story)
878    pub fn reset(&mut self) {
879        self.completed.clear();
880        self.current = None;
881    }
882
883    /// Enter a new state, marking any current state as completed
884    pub fn enter_state(&mut self, state: BreadcrumbState) {
885        // Mark current state as completed if it exists
886        if let Some(current) = self.current.take() {
887            self.completed.push(current);
888        }
889        self.current = Some(state);
890    }
891
892    /// Mark current state as completed without entering a new one
893    pub fn complete_current(&mut self) {
894        if let Some(current) = self.current.take() {
895            self.completed.push(current);
896        }
897    }
898
899    /// Get the list of completed states
900    pub fn completed_states(&self) -> &[BreadcrumbState] {
901        &self.completed
902    }
903
904    /// Get the current state if any
905    pub fn current_state(&self) -> Option<&BreadcrumbState> {
906        self.current.as_ref()
907    }
908
909    /// Check if the breadcrumb trail is empty
910    pub fn is_empty(&self) -> bool {
911        self.completed.is_empty() && self.current.is_none()
912    }
913
914    /// Render the breadcrumb trail as a colored string.
915    ///
916    /// Format: `Journey: Story → Review → Correct → Review`
917    /// - Completed states in green
918    /// - Current state in yellow
919    /// - Uses `→` separator in gray/dim color
920    /// - Truncates with `...` if too long for terminal
921    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        // Build the trail parts
931        let mut parts: Vec<String> = Vec::new();
932
933        // Add completed states in green
934        for state in &self.completed {
935            parts.push(format!("{GREEN}{}{RESET}", state.display_name()));
936        }
937
938        // Add current state in yellow
939        if let Some(current) = &self.current {
940            parts.push(format!("{YELLOW}{}{RESET}", current.display_name()));
941        }
942
943        // Calculate plain text length for truncation (without ANSI codes)
944        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 the trail fits, return it
957        if plain_len <= max_width {
958            return format!("{}{}", prefix, parts.join(&separator));
959        }
960
961        // Need to truncate - show as many recent states as possible with "..."
962        let ellipsis = "...";
963        let available = max_width.saturating_sub(plain_prefix.len() + ellipsis.len() + 4); // 4 for " → " after ellipsis
964
965        // Start from the end and work backwards to fit as many states as possible
966        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        // First, always include current state if it exists
971        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        // Add completed states from most recent to oldest until we run out of space
978        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 we couldn't fit all states, prepend ellipsis
996        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    /// Print the breadcrumb trail to stdout if it's not empty
1009    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    // ========================================================================
1021    // Core text truncation tests
1022    // ========================================================================
1023
1024    #[test]
1025    fn test_truncate_activity() {
1026        // Short - no truncation
1027        assert_eq!(truncate_activity("Short message", 50), "Short message");
1028
1029        // Long - truncation with ellipsis
1030        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        // Multiline - only first line
1036        assert_eq!(
1037            truncate_activity("First line\nSecond line", 50),
1038            "First line"
1039        );
1040
1041        // UTF-8 handling
1042        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        // Short text - padded
1050        let result = fixed_width_activity("Working");
1051        assert_eq!(result.chars().count(), ACTIVITY_TEXT_WIDTH);
1052        assert!(result.starts_with("Working"));
1053
1054        // Long text - truncated
1055        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        // Empty - all spaces
1061        let result = fixed_width_activity("");
1062        assert_eq!(result.chars().count(), ACTIVITY_TEXT_WIDTH);
1063        assert!(result.chars().all(|c| c == ' '));
1064    }
1065
1066    // ========================================================================
1067    // Duration formatting
1068    // ========================================================================
1069
1070    #[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    // ========================================================================
1079    // IterationInfo formatting
1080    // ========================================================================
1081
1082    #[test]
1083    fn test_iteration_info_format() {
1084        // With phase and counts
1085        let info = IterationInfo::with_phase("Review", 1, 3);
1086        assert_eq!(info.format(), Some("[Review 1/3]".to_string()));
1087
1088        // Phase only
1089        let info = IterationInfo::phase_only("Commit");
1090        assert_eq!(info.format(), Some("[Commit]".to_string()));
1091
1092        // Counts only
1093        let info = IterationInfo::new(2, 5);
1094        assert_eq!(info.format(), Some("[2/5]".to_string()));
1095
1096        // Default - None
1097        assert_eq!(IterationInfo::default().format(), None);
1098    }
1099
1100    // ========================================================================
1101    // Display prefix formatting
1102    // ========================================================================
1103
1104    #[test]
1105    fn test_format_display_prefix() {
1106        // With iteration info
1107        let info = IterationInfo::with_phase("Review", 1, 3);
1108        assert_eq!(format_display_prefix("Review", &Some(info)), "[Review 1/3]");
1109
1110        // Without info - falls back to story_id
1111        assert_eq!(format_display_prefix("US-001", &None), "US-001");
1112
1113        // Spec special case
1114        assert_eq!(format_display_prefix("Spec", &None), "Spec generation");
1115    }
1116
1117    // ========================================================================
1118    // ProgressContext dual-context formatting
1119    // ========================================================================
1120
1121    #[test]
1122    fn test_progress_context_dual_context() {
1123        let ctx = ProgressContext::new("US-001", 2, 5);
1124
1125        // Both present
1126        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        // Story only
1133        assert_eq!(
1134            ctx.format_dual_context(&None),
1135            Some("[US-001 2/5]".to_string())
1136        );
1137
1138        // Neither
1139        let empty_ctx = ProgressContext::default();
1140        assert_eq!(empty_ctx.format_dual_context(&None), None);
1141    }
1142
1143    // ========================================================================
1144    // Breadcrumb state management
1145    // ========================================================================
1146
1147    #[test]
1148    fn test_breadcrumb_workflow() {
1149        let mut breadcrumb = Breadcrumb::new();
1150        assert!(breadcrumb.is_empty());
1151
1152        // Enter states
1153        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        // Reset
1162        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    // ========================================================================
1180    // Spinner lifecycle (minimal - just verify cleanup works)
1181    // ========================================================================
1182
1183    #[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    // ========================================================================
1206    // Drop cleanup verification
1207    // ========================================================================
1208
1209    #[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    // ========================================================================
1221    // US-007: Token formatting and display tests
1222    // ========================================================================
1223
1224    #[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}