Skip to main content

reflex/semantic/
reporter.rs

1//! Progress reporting for agentic loop
2//!
3//! This module provides transparent "show your work" output for the agentic loop,
4//! displaying the LLM's reasoning at each phase similar to Claude Code's thinking blocks.
5
6use indicatif::ProgressBar;
7use owo_colors::OwoColorize;
8use std::sync::{Arc, Mutex};
9
10use super::schema_agentic::{EvaluationReport, ToolCall};
11use super::tools::ToolResult;
12
13/// Trait for reporting agentic loop progress
14pub trait AgenticReporter: Send + Sync {
15    /// Report assessment phase completion
16    fn report_assessment(&self, reasoning: &str, needs_context: bool, tools: &[ToolCall]);
17
18    /// Report start of tool execution
19    fn report_tool_start(&self, idx: usize, tool: &ToolCall);
20
21    /// Report tool execution completion
22    fn report_tool_complete(&self, idx: usize, result: &ToolResult);
23
24    /// Report query generation completion
25    fn report_generation(&self, reasoning: Option<&str>, query_count: usize, confidence: f32);
26
27    /// Report evaluation results
28    fn report_evaluation(&self, evaluation: &EvaluationReport);
29
30    /// Report refinement start
31    fn report_refinement_start(&self);
32
33    /// Report phase start
34    fn report_phase(&self, phase_num: usize, phase_name: &str);
35
36    /// Report reindexing progress (when cache needs to be rebuilt)
37    fn report_reindex_progress(&self, current: usize, total: usize, message: String);
38
39    /// Clear all ephemeral output (called before final results are shown)
40    fn clear_all(&self);
41}
42
43/// Console reporter with colored output and ephemeral thinking
44pub struct ConsoleReporter {
45    /// Show LLM reasoning blocks
46    show_reasoning: bool,
47
48    /// Verbose output (show tool results, etc.)
49    verbose: bool,
50
51    /// Debug mode: disable ephemeral clearing to retain all output
52    debug: bool,
53
54    /// Number of lines printed by the last phase (for ephemeral clearing)
55    lines_printed: Mutex<usize>,
56
57    /// Optional progress spinner to update with phase information
58    spinner: Option<Arc<Mutex<ProgressBar>>>,
59}
60
61impl ConsoleReporter {
62    /// Create a new console reporter
63    pub fn new(
64        show_reasoning: bool,
65        verbose: bool,
66        debug: bool,
67        spinner: Option<Arc<Mutex<ProgressBar>>>,
68    ) -> Self {
69        Self {
70            show_reasoning,
71            verbose,
72            debug,
73            lines_printed: Mutex::new(0),
74            spinner,
75        }
76    }
77
78    /// Clear the last N lines of output (for ephemeral display)
79    fn clear_last_output(&self) {
80        // Skip clearing in debug mode to retain all output
81        if self.debug {
82            return;
83        }
84
85        let lines = *self.lines_printed.lock().unwrap();
86        if lines > 0 {
87            for _ in 0..lines {
88                // Move cursor up one line and clear it
89                eprint!("\x1b[1A\x1b[2K");
90            }
91            *self.lines_printed.lock().unwrap() = 0;
92        }
93    }
94
95    /// Track that N lines were printed
96    fn add_lines(&self, count: usize) {
97        *self.lines_printed.lock().unwrap() += count;
98    }
99
100    /// Count lines in a string
101    #[allow(dead_code)]
102    fn count_lines(text: &str) -> usize {
103        if text.is_empty() {
104            0
105        } else {
106            text.lines().count()
107        }
108    }
109
110    /// Display formatted reasoning block with line prefix (dark gray like Claude Code)
111    fn display_reasoning_block(&self, reasoning: &str) {
112        let mut line_count = 0;
113        for line in reasoning.lines() {
114            if line.trim().is_empty() {
115                println!();
116            } else {
117                // Use ANSI bright black (dark gray) for thinking text
118                println!("  \x1b[90m{}\x1b[0m", line);
119            }
120            line_count += 1;
121        }
122        self.add_lines(line_count);
123    }
124
125    /// Describe a tool for display
126    fn describe_tool(&self, tool: &ToolCall) -> String {
127        match tool {
128            ToolCall::GatherContext { params } => {
129                let mut parts = Vec::new();
130                if params.structure {
131                    parts.push("structure");
132                }
133                if params.file_types {
134                    parts.push("file types");
135                }
136                if params.project_type {
137                    parts.push("project type");
138                }
139                if params.framework {
140                    parts.push("frameworks");
141                }
142                if params.entry_points {
143                    parts.push("entry points");
144                }
145                if params.test_layout {
146                    parts.push("test layout");
147                }
148                if params.config_files {
149                    parts.push("config files");
150                }
151
152                if parts.is_empty() {
153                    "gather_context: General codebase context".to_string()
154                } else {
155                    format!("gather_context: {}", parts.join(", "))
156                }
157            }
158            ToolCall::ExploreCodebase {
159                description,
160                command,
161            } => {
162                format!("explore_codebase: {} ({})", description, command)
163            }
164            ToolCall::AnalyzeStructure { analysis_type } => {
165                format!("analyze_structure: {:?}", analysis_type)
166            }
167            ToolCall::SearchDocumentation { query, files } => {
168                if let Some(file_list) = files {
169                    format!("search_documentation: '{}' in files {:?}", query, file_list)
170                } else {
171                    format!("search_documentation: '{}'", query)
172                }
173            }
174            ToolCall::GetStatistics => "get_statistics: Retrieve index statistics".to_string(),
175            ToolCall::GetDependencies { file_path, reverse } => {
176                if *reverse {
177                    format!("get_dependencies: Reverse deps for '{}'", file_path)
178                } else {
179                    format!("get_dependencies: Dependencies of '{}'", file_path)
180                }
181            }
182            ToolCall::GetAnalysisSummary { min_dependents } => {
183                format!(
184                    "get_analysis_summary: Dependency analysis (min_dependents={})",
185                    min_dependents
186                )
187            }
188            ToolCall::FindIslands { min_size, max_size } => {
189                format!(
190                    "find_islands: Disconnected components (size {}-{})",
191                    min_size, max_size
192                )
193            }
194        }
195    }
196
197    /// Truncate text for preview display
198    fn truncate(&self, text: &str, max_len: usize) -> String {
199        if text.len() <= max_len {
200            return text.to_string();
201        }
202
203        let truncated = &text[..max_len];
204        format!("{}...", truncated)
205    }
206
207    /// Execute a closure with the spinner suspended
208    /// This prevents visual conflicts between spinner and printed output
209    fn with_suspended_spinner<F, R>(&self, f: F) -> R
210    where
211        F: FnOnce() -> R,
212    {
213        if let Some(ref spinner) = self.spinner
214            && let Ok(spinner_guard) = spinner.lock()
215        {
216            return spinner_guard.suspend(f);
217        }
218        // If no spinner or lock failed, just execute the closure
219        f()
220    }
221}
222
223impl AgenticReporter for ConsoleReporter {
224    fn report_phase(&self, phase_num: usize, phase_name: &str) {
225        if let Some(ref spinner) = self.spinner {
226            // Lock spinner once for suspend, print, and finish
227            if let Ok(spinner_guard) = spinner.lock() {
228                // Suspend spinner, print output
229                spinner_guard.suspend(|| {
230                    let line = format!("\n━━━ Phase {}: {} ━━━", phase_num, phase_name);
231                    println!("{}", line.bold().cyan());
232                    self.add_lines(2); // Newline + phase line
233                });
234                // Finish and clear the spinner completely to hide it
235                // It will automatically reappear when set_message() is called with a non-empty message
236                spinner_guard.finish_and_clear();
237            }
238        } else {
239            // No spinner, just print
240            let line = format!("\n━━━ Phase {}: {} ━━━", phase_num, phase_name);
241            println!("{}", line.bold().cyan());
242            self.add_lines(2); // Newline + phase line
243        }
244    }
245
246    fn report_assessment(&self, reasoning: &str, needs_context: bool, tools: &[ToolCall]) {
247        self.report_phase(1, "Assessment");
248
249        self.with_suspended_spinner(|| {
250            if self.show_reasoning && !reasoning.is_empty() {
251                println!("\n{}", "💭 Reasoning:".dimmed());
252                self.add_lines(2); // Newline + header
253                self.display_reasoning_block(reasoning);
254            }
255
256            println!();
257            self.add_lines(1);
258
259            if needs_context && !tools.is_empty() {
260                println!(
261                    "{} {}",
262                    "→".bright_green(),
263                    "Needs additional context".bold()
264                );
265                println!("  {} tool(s) to execute:", tools.len());
266                self.add_lines(2);
267                for (i, tool) in tools.iter().enumerate() {
268                    println!(
269                        "  {}. {}",
270                        (i + 1).to_string().bright_white(),
271                        self.describe_tool(tool).dimmed()
272                    );
273                    self.add_lines(1);
274                }
275            } else {
276                println!("{} {}", "→".bright_green(), "Has sufficient context".bold());
277                println!("  Proceeding directly to query generation");
278                self.add_lines(2);
279            }
280        });
281    }
282
283    fn report_tool_start(&self, idx: usize, tool: &ToolCall) {
284        if idx == 1 {
285            self.report_phase(2, "Context Gathering");
286            self.with_suspended_spinner(|| {
287                println!();
288                self.add_lines(1);
289            });
290        }
291
292        if self.verbose {
293            self.with_suspended_spinner(|| {
294                println!(
295                    "  {} Executing: {}",
296                    "⋯".dimmed(),
297                    self.describe_tool(tool).dimmed()
298                );
299                self.add_lines(1);
300            });
301        }
302    }
303
304    fn report_tool_complete(&self, idx: usize, result: &ToolResult) {
305        self.with_suspended_spinner(|| {
306            if result.success {
307                println!(
308                    "  {} {} {}",
309                    "✓".bright_green(),
310                    format!("[{}]", idx).dimmed(),
311                    result.description
312                );
313                self.add_lines(1);
314
315                if self.verbose && !result.output.is_empty() {
316                    // Show truncated output
317                    let preview = self.truncate(&result.output, 150);
318                    let lines_shown = preview.lines().take(3);
319                    for line in lines_shown {
320                        println!("    {}", line.dimmed());
321                        self.add_lines(1);
322                    }
323                    if result.output.lines().count() > 3 {
324                        println!("    {}", "...".dimmed());
325                        self.add_lines(1);
326                    }
327                }
328            } else {
329                println!(
330                    "  {} {} {} - {}",
331                    "✗".bright_red(),
332                    format!("[{}]", idx).dimmed(),
333                    result.description,
334                    "failed".red()
335                );
336                self.add_lines(1);
337            }
338        });
339    }
340
341    fn report_generation(&self, reasoning: Option<&str>, query_count: usize, confidence: f32) {
342        // Clear all previous output (assessment + tools are ephemeral)
343        self.clear_last_output();
344
345        self.report_phase(3, "Query Generation");
346
347        self.with_suspended_spinner(|| {
348            if self.show_reasoning
349                && let Some(reasoning_text) = reasoning
350                && !reasoning_text.is_empty()
351            {
352                println!("\n{}", "💭 Reasoning:".dimmed());
353                self.add_lines(2);
354                self.display_reasoning_block(reasoning_text);
355            }
356
357            println!();
358            self.add_lines(1);
359
360            let confidence_pct = (confidence * 100.0) as u8;
361
362            print!(
363                "{} Generated {} {} (confidence: ",
364                "→".bright_green(),
365                query_count,
366                if query_count == 1 { "query" } else { "queries" }
367            );
368
369            if confidence >= 0.8 {
370                println!("{}%)", confidence_pct.to_string().bright_green());
371            } else if confidence >= 0.6 {
372                println!("{}%)", confidence_pct.to_string().yellow());
373            } else {
374                println!("{}%)", confidence_pct.to_string().bright_red());
375            }
376            self.add_lines(1);
377        });
378    }
379
380    fn report_evaluation(&self, evaluation: &EvaluationReport) {
381        // Clear query generation output (ephemeral)
382        self.clear_last_output();
383
384        self.report_phase(5, "Evaluation");
385
386        self.with_suspended_spinner(|| {
387            println!();
388            self.add_lines(1);
389
390            if evaluation.success {
391                println!(
392                    "{} {} (score: {}/1.0)",
393                    "✓".bright_green(),
394                    "Success".bold().bright_green(),
395                    format!("{:.2}", evaluation.score).bright_white()
396                );
397                self.add_lines(1);
398
399                if self.verbose && !evaluation.issues.is_empty() {
400                    println!("\n  Minor issues noted:");
401                    self.add_lines(2);
402                    for issue in &evaluation.issues {
403                        println!(
404                            "  - {} (severity: {:.2})",
405                            issue.description.dimmed(),
406                            issue.severity
407                        );
408                        self.add_lines(1);
409                    }
410                }
411            } else {
412                println!(
413                    "{} {} (score: {}/1.0)",
414                    "⚠".yellow(),
415                    "Results need refinement".bold().yellow(),
416                    format!("{:.2}", evaluation.score).bright_white()
417                );
418                self.add_lines(1);
419
420                if !evaluation.issues.is_empty() {
421                    println!("\n  Issues found:");
422                    self.add_lines(2);
423                    for (idx, issue) in evaluation.issues.iter().enumerate().take(3) {
424                        println!(
425                            "  {}. {}",
426                            (idx + 1).to_string().dimmed(),
427                            issue.description
428                        );
429                        self.add_lines(1);
430                    }
431                }
432
433                if !evaluation.suggestions.is_empty() {
434                    println!("\n  Suggestions:");
435                    self.add_lines(2);
436                    for (idx, suggestion) in evaluation.suggestions.iter().enumerate().take(3) {
437                        println!(
438                            "  {}. {}",
439                            (idx + 1).to_string().dimmed(),
440                            suggestion.dimmed()
441                        );
442                        self.add_lines(1);
443                    }
444                }
445            }
446        });
447    }
448
449    fn report_refinement_start(&self) {
450        // Clear evaluation output (ephemeral)
451        self.clear_last_output();
452
453        self.report_phase(6, "Refinement");
454
455        self.with_suspended_spinner(|| {
456            println!();
457            println!(
458                "{} Refining queries based on evaluation feedback...",
459                "→".yellow()
460            );
461            self.add_lines(2);
462        });
463    }
464
465    fn report_reindex_progress(&self, current: usize, total: usize, message: String) {
466        self.with_suspended_spinner(|| {
467            // Update the current line with progress
468            if current > 0 {
469                // Clear previous progress line
470                eprint!("\r\x1b[2K");
471            }
472
473            let percentage = if total > 0 {
474                (current as f32 / total as f32 * 100.0) as u8
475            } else {
476                0
477            };
478
479            eprint!(
480                "  {} Reindexing cache: [{}/{}] {}% - {}",
481                "⋯".yellow(),
482                current,
483                total,
484                percentage,
485                message.dimmed()
486            );
487
488            // Flush to ensure immediate display
489            use std::io::Write;
490            let _ = std::io::stderr().flush();
491
492            // If we're done, add a newline
493            if current >= total {
494                eprintln!();
495                self.add_lines(1);
496            }
497        });
498    }
499
500    fn clear_all(&self) {
501        // Clear all ephemeral output before showing final results
502        // (skip in debug mode to retain terminal history)
503        self.clear_last_output();
504    }
505}
506
507/// No-op reporter for quiet mode
508pub struct QuietReporter;
509
510impl AgenticReporter for QuietReporter {
511    fn report_assessment(&self, _reasoning: &str, _needs_context: bool, _tools: &[ToolCall]) {}
512    fn report_tool_start(&self, _idx: usize, _tool: &ToolCall) {}
513    fn report_tool_complete(&self, _idx: usize, _result: &ToolResult) {}
514    fn report_generation(&self, _reasoning: Option<&str>, _query_count: usize, _confidence: f32) {}
515    fn report_evaluation(&self, _evaluation: &EvaluationReport) {}
516    fn report_refinement_start(&self) {}
517    fn report_phase(&self, _phase_num: usize, _phase_name: &str) {}
518    fn report_reindex_progress(&self, _current: usize, _total: usize, _message: String) {}
519    fn clear_all(&self) {}
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::semantic::schema_agentic::*;
526
527    #[test]
528    fn test_console_reporter_creation() {
529        let reporter = ConsoleReporter::new(true, false, false, None);
530        assert!(reporter.show_reasoning);
531        assert!(!reporter.verbose);
532        assert!(!reporter.debug);
533    }
534
535    #[test]
536    fn test_truncate() {
537        let reporter = ConsoleReporter::new(false, false, false, None);
538        let text = "a".repeat(300);
539        let truncated = reporter.truncate(&text, 100);
540        assert!(truncated.len() <= 103); // 100 + "..."
541    }
542
543    #[test]
544    fn test_describe_gather_context_tool() {
545        let reporter = ConsoleReporter::new(false, false, false, None);
546        let tool = ToolCall::GatherContext {
547            params: ContextGatheringParams {
548                structure: true,
549                file_types: true,
550                ..Default::default()
551            },
552        };
553
554        let desc = reporter.describe_tool(&tool);
555        assert!(desc.contains("gather_context"));
556        assert!(desc.contains("structure"));
557    }
558}