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