mixtape_cli/repl/
presentation.rs

1//! Tool presentation formatting for CLI output
2
3use super::commands::Verbosity;
4use super::formatter::ToolFormatter;
5use mixtape_core::{Agent, AgentEvent, AgentHook, Display, ToolApprovalStatus};
6use std::sync::{Arc, Mutex};
7
8/// Hook that presents tool calls with rich CLI formatting
9///
10/// Generic over `F: ToolFormatter` to enable testing with mock formatters.
11/// Defaults to `Agent` for normal usage.
12pub struct PresentationHook<F: ToolFormatter = Agent> {
13    formatter: Arc<F>,
14    verbosity: Arc<Mutex<Verbosity>>,
15}
16
17impl<F: ToolFormatter> PresentationHook<F> {
18    pub fn new(formatter: Arc<F>, verbosity: Arc<Mutex<Verbosity>>) -> Self {
19        Self {
20            formatter,
21            verbosity,
22        }
23    }
24}
25
26impl<F: ToolFormatter + 'static> AgentHook for PresentationHook<F> {
27    fn on_event(&self, event: &AgentEvent) {
28        match event {
29            AgentEvent::ToolStarted {
30                name,
31                input,
32                approval_status,
33                ..
34            } => {
35                let verbosity = *self.verbosity.lock().unwrap();
36                if !should_print_start(name, approval_status, verbosity) {
37                    return;
38                }
39                let formatted = self
40                    .formatter
41                    .format_tool_input(name, input, Display::Cli)
42                    .and_then(|formatted| format_tool_input(name, &formatted, verbosity));
43
44                let show_approval = matches!(approval_status, ToolApprovalStatus::UserApproved);
45                if formatted.is_none() && !show_approval {
46                    return;
47                }
48
49                println!("\n🛠️  \x1b[1m{}\x1b[0m", name);
50                if let Some(output) = formatted {
51                    println!("{}", indent_lines(&output));
52                }
53                if show_approval {
54                    println!("  \x1b[33m(user approved)\x1b[0m");
55                }
56            }
57            AgentEvent::ToolCompleted { name, output, .. } => {
58                let verbosity = *self.verbosity.lock().unwrap();
59                if verbosity == Verbosity::Quiet {
60                    println!("\n\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", name);
61                    return;
62                }
63                println!("\n\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", name);
64
65                // Format tool output on-demand using CLIPresenter
66                if let Some(formatted) =
67                    self.formatter
68                        .format_tool_output(name, output, Display::Cli)
69                {
70                    if let Some(output) = format_tool_output(name, &formatted, verbosity) {
71                        println!("{}", indent_lines(&output));
72                    } else {
73                        println!("  (completed)");
74                    }
75                } else {
76                    println!("  (completed)");
77                }
78            }
79            AgentEvent::ToolFailed { name, error, .. } => {
80                println!("\n\x1b[31m✗\x1b[0m \x1b[1m{}\x1b[0m", name);
81                println!("{}", indent_lines(&format!("\x1b[31m{}\x1b[0m", error)));
82            }
83            _ => {}
84        }
85    }
86}
87
88fn should_print_start(
89    tool_name: &str,
90    approval_status: &ToolApprovalStatus,
91    verbosity: Verbosity,
92) -> bool {
93    if verbosity == Verbosity::Verbose {
94        return true;
95    }
96    if verbosity == Verbosity::Quiet {
97        return false;
98    }
99    tool_is_long_running(tool_name) || matches!(approval_status, ToolApprovalStatus::UserApproved)
100}
101
102fn format_tool_input(tool_name: &str, formatted: &str, verbosity: Verbosity) -> Option<String> {
103    if verbosity == Verbosity::Quiet {
104        return None;
105    }
106    if verbosity == Verbosity::Verbose {
107        return Some(formatted.to_string());
108    }
109    if tool_is_noisy(tool_name) {
110        return None;
111    }
112    Some(formatted.to_string())
113}
114
115fn format_tool_output(tool_name: &str, formatted: &str, verbosity: Verbosity) -> Option<String> {
116    if verbosity == Verbosity::Quiet {
117        return None;
118    }
119    if verbosity == Verbosity::Verbose {
120        return Some(formatted.to_string());
121    }
122    // Check for empty content before applying any formatting
123    if formatted.trim().is_empty() {
124        return None;
125    }
126    // Truncation is now handled by Tool::present_output_cli()
127    let output = if tool_is_dimmed(tool_name) {
128        dim_text(formatted)
129    } else {
130        formatted.to_string()
131    };
132    Some(output)
133}
134
135fn tool_is_long_running(tool_name: &str) -> bool {
136    matches!(
137        tool_name,
138        "start_process"
139            | "read_process_output"
140            | "interact_with_process"
141            | "list_processes"
142            | "list_sessions"
143            | "search"
144            | "fetch"
145            | "list_directory"
146    )
147}
148
149fn tool_is_dimmed(tool_name: &str) -> bool {
150    matches!(
151        tool_name,
152        "start_process" | "read_process_output" | "interact_with_process"
153    )
154}
155
156fn tool_is_noisy(tool_name: &str) -> bool {
157    matches!(
158        tool_name,
159        "list_directory" | "search" | "list_processes" | "list_sessions"
160    )
161}
162
163fn dim_text(text: &str) -> String {
164    format!("\x1b[2m{}\x1b[0m", text)
165}
166
167pub fn indent_lines(text: &str) -> String {
168    if text.is_empty() {
169        return String::new();
170    }
171    let mut lines = text.lines();
172    let Some(first) = lines.next() else {
173        return String::new();
174    };
175    let mut output = format!("  └ {}", first);
176    for line in lines {
177        output.push_str(&format!("\n    {}", line));
178    }
179    output
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    mod indent_lines_tests {
187        use super::*;
188
189        #[test]
190        fn empty_string_returns_empty() {
191            assert_eq!(indent_lines(""), "");
192        }
193
194        #[test]
195        fn single_line_gets_prefix() {
196            assert_eq!(indent_lines("hello"), "  └ hello");
197        }
198
199        #[test]
200        fn multiline_indents_continuation() {
201            let input = "line1\nline2\nline3";
202            let expected = "  └ line1\n    line2\n    line3";
203            assert_eq!(indent_lines(input), expected);
204        }
205
206        #[test]
207        fn handles_empty_lines_in_middle() {
208            let input = "line1\n\nline3";
209            let expected = "  └ line1\n    \n    line3";
210            assert_eq!(indent_lines(input), expected);
211        }
212
213        #[test]
214        fn preserves_existing_indentation() {
215            let input = "func() {\n    body\n}";
216            let expected = "  └ func() {\n        body\n    }";
217            assert_eq!(indent_lines(input), expected);
218        }
219    }
220
221    mod tool_classification_tests {
222        use super::*;
223
224        #[test]
225        fn long_running_tools_identified() {
226            assert!(tool_is_long_running("start_process"));
227            assert!(tool_is_long_running("read_process_output"));
228            assert!(tool_is_long_running("interact_with_process"));
229            assert!(tool_is_long_running("list_processes"));
230            assert!(tool_is_long_running("list_sessions"));
231            assert!(tool_is_long_running("search"));
232            assert!(tool_is_long_running("fetch"));
233            assert!(tool_is_long_running("list_directory"));
234        }
235
236        #[test]
237        fn non_long_running_tools_not_flagged() {
238            assert!(!tool_is_long_running("read_file"));
239            assert!(!tool_is_long_running("write_file"));
240            assert!(!tool_is_long_running("unknown_tool"));
241        }
242
243        #[test]
244        fn dimmed_tools_identified() {
245            assert!(tool_is_dimmed("start_process"));
246            assert!(tool_is_dimmed("read_process_output"));
247            assert!(tool_is_dimmed("interact_with_process"));
248        }
249
250        #[test]
251        fn non_dimmed_tools_not_flagged() {
252            assert!(!tool_is_dimmed("read_file"));
253            assert!(!tool_is_dimmed("search"));
254            assert!(!tool_is_dimmed("fetch"));
255        }
256
257        #[test]
258        fn noisy_tools_identified() {
259            assert!(tool_is_noisy("list_directory"));
260            assert!(tool_is_noisy("search"));
261            assert!(tool_is_noisy("list_processes"));
262            assert!(tool_is_noisy("list_sessions"));
263        }
264
265        #[test]
266        fn non_noisy_tools_not_flagged() {
267            assert!(!tool_is_noisy("read_file"));
268            assert!(!tool_is_noisy("fetch"));
269            assert!(!tool_is_noisy("start_process"));
270        }
271    }
272
273    mod dim_text_tests {
274        use super::*;
275
276        #[test]
277        fn wraps_text_with_ansi_codes() {
278            let result = dim_text("hello");
279            assert_eq!(result, "\x1b[2mhello\x1b[0m");
280        }
281
282        #[test]
283        fn handles_empty_string() {
284            let result = dim_text("");
285            assert_eq!(result, "\x1b[2m\x1b[0m");
286        }
287
288        #[test]
289        fn handles_multiline_text() {
290            let result = dim_text("line1\nline2");
291            assert_eq!(result, "\x1b[2mline1\nline2\x1b[0m");
292        }
293    }
294
295    mod should_print_start_tests {
296        use super::*;
297
298        #[test]
299        fn verbose_always_prints() {
300            assert!(should_print_start(
301                "any_tool",
302                &ToolApprovalStatus::AutoApproved,
303                Verbosity::Verbose
304            ));
305            assert!(should_print_start(
306                "read_file",
307                &ToolApprovalStatus::AutoApproved,
308                Verbosity::Verbose
309            ));
310        }
311
312        #[test]
313        fn quiet_never_prints() {
314            assert!(!should_print_start(
315                "start_process",
316                &ToolApprovalStatus::UserApproved,
317                Verbosity::Quiet
318            ));
319            assert!(!should_print_start(
320                "search",
321                &ToolApprovalStatus::AutoApproved,
322                Verbosity::Quiet
323            ));
324        }
325
326        #[test]
327        fn normal_prints_long_running() {
328            assert!(should_print_start(
329                "start_process",
330                &ToolApprovalStatus::AutoApproved,
331                Verbosity::Normal
332            ));
333            assert!(should_print_start(
334                "search",
335                &ToolApprovalStatus::AutoApproved,
336                Verbosity::Normal
337            ));
338        }
339
340        #[test]
341        fn normal_prints_user_approved() {
342            assert!(should_print_start(
343                "read_file",
344                &ToolApprovalStatus::UserApproved,
345                Verbosity::Normal
346            ));
347        }
348
349        #[test]
350        fn normal_skips_auto_approved_short_tools() {
351            assert!(!should_print_start(
352                "read_file",
353                &ToolApprovalStatus::AutoApproved,
354                Verbosity::Normal
355            ));
356        }
357    }
358
359    mod format_tool_input_tests {
360        use super::*;
361
362        #[test]
363        fn quiet_returns_none() {
364            assert!(format_tool_input("any_tool", "content", Verbosity::Quiet).is_none());
365        }
366
367        #[test]
368        fn verbose_always_returns_content() {
369            let result = format_tool_input("list_directory", "content", Verbosity::Verbose);
370            assert_eq!(result, Some("content".to_string()));
371        }
372
373        #[test]
374        fn normal_filters_noisy_tools() {
375            assert!(format_tool_input("list_directory", "content", Verbosity::Normal).is_none());
376            assert!(format_tool_input("search", "content", Verbosity::Normal).is_none());
377        }
378
379        #[test]
380        fn normal_shows_non_noisy_tools() {
381            let result = format_tool_input("read_file", "content", Verbosity::Normal);
382            assert_eq!(result, Some("content".to_string()));
383        }
384    }
385
386    mod format_tool_output_tests {
387        use super::*;
388
389        #[test]
390        fn quiet_returns_none() {
391            assert!(format_tool_output("any_tool", "content", Verbosity::Quiet).is_none());
392        }
393
394        #[test]
395        fn verbose_returns_content_as_is() {
396            let result = format_tool_output("start_process", "output", Verbosity::Verbose);
397            assert_eq!(result, Some("output".to_string()));
398        }
399
400        #[test]
401        fn normal_dims_dimmed_tools() {
402            let result = format_tool_output("start_process", "output", Verbosity::Normal);
403            assert_eq!(result, Some("\x1b[2moutput\x1b[0m".to_string()));
404        }
405
406        #[test]
407        fn normal_does_not_dim_other_tools() {
408            let result = format_tool_output("read_file", "output", Verbosity::Normal);
409            assert_eq!(result, Some("output".to_string()));
410        }
411
412        #[test]
413        fn empty_output_returns_none() {
414            assert!(format_tool_output("read_file", "", Verbosity::Normal).is_none());
415            assert!(format_tool_output("read_file", "   ", Verbosity::Normal).is_none());
416            assert!(format_tool_output("read_file", "\n\t  ", Verbosity::Normal).is_none());
417        }
418
419        #[test]
420        fn whitespace_only_dimmed_returns_none() {
421            // Even dimmed whitespace should return None
422            assert!(format_tool_output("start_process", "  ", Verbosity::Normal).is_none());
423        }
424    }
425
426    mod presentation_hook_tests {
427        use super::*;
428        use crate::repl::formatter::ToolFormatter;
429        use mixtape_core::ToolResult;
430        use serde_json::{json, Value};
431        use std::time::Instant;
432
433        /// Mock formatter for testing PresentationHook
434        struct MockFormatter {
435            input_result: Option<String>,
436            output_result: Option<String>,
437        }
438
439        impl MockFormatter {
440            fn new() -> Self {
441                Self {
442                    input_result: None,
443                    output_result: None,
444                }
445            }
446
447            fn with_input(mut self, result: Option<&str>) -> Self {
448                self.input_result = result.map(String::from);
449                self
450            }
451
452            fn with_output(mut self, result: Option<&str>) -> Self {
453                self.output_result = result.map(String::from);
454                self
455            }
456        }
457
458        impl ToolFormatter for MockFormatter {
459            fn format_tool_input(
460                &self,
461                _name: &str,
462                _input: &Value,
463                _display: Display,
464            ) -> Option<String> {
465                self.input_result.clone()
466            }
467
468            fn format_tool_output(
469                &self,
470                _name: &str,
471                _output: &ToolResult,
472                _display: Display,
473            ) -> Option<String> {
474                self.output_result.clone()
475            }
476        }
477
478        fn create_hook(
479            formatter: MockFormatter,
480            verbosity: Verbosity,
481        ) -> PresentationHook<MockFormatter> {
482            PresentationHook::new(Arc::new(formatter), Arc::new(Mutex::new(verbosity)))
483        }
484
485        fn tool_started_event(name: &str, approval: ToolApprovalStatus) -> AgentEvent {
486            AgentEvent::ToolStarted {
487                id: "test-id".to_string(),
488                name: name.to_string(),
489                input: json!({"query": "test"}),
490                approval_status: approval,
491                timestamp: Instant::now(),
492            }
493        }
494
495        fn tool_completed_event(name: &str) -> AgentEvent {
496            AgentEvent::ToolCompleted {
497                id: "test-id".to_string(),
498                name: name.to_string(),
499                output: ToolResult::Text("result".to_string()),
500                approval_status: ToolApprovalStatus::AutoApproved,
501                duration: std::time::Duration::from_millis(100),
502            }
503        }
504
505        fn tool_failed_event(name: &str, error: &str) -> AgentEvent {
506            AgentEvent::ToolFailed {
507                id: "test-id".to_string(),
508                name: name.to_string(),
509                error: error.to_string(),
510                duration: std::time::Duration::from_millis(50),
511            }
512        }
513
514        // Tests for PresentationHook construction
515        #[test]
516        fn hook_can_be_created_with_mock_formatter() {
517            let hook = create_hook(MockFormatter::new(), Verbosity::Normal);
518            // Just verify it compiles and creates successfully
519            assert!(Arc::strong_count(&hook.formatter) >= 1);
520        }
521
522        // Tests for ToolStarted event handling
523        #[test]
524        fn tool_started_quiet_mode_does_not_panic() {
525            let hook = create_hook(
526                MockFormatter::new().with_input(Some("formatted input")),
527                Verbosity::Quiet,
528            );
529            // In quiet mode, should return early without printing
530            hook.on_event(&tool_started_event(
531                "search",
532                ToolApprovalStatus::AutoApproved,
533            ));
534        }
535
536        #[test]
537        fn tool_started_long_running_tool_processes_event() {
538            let hook = create_hook(
539                MockFormatter::new().with_input(Some("query: test")),
540                Verbosity::Normal,
541            );
542            // Long-running tools should be processed in Normal mode
543            hook.on_event(&tool_started_event(
544                "search",
545                ToolApprovalStatus::AutoApproved,
546            ));
547        }
548
549        #[test]
550        fn tool_started_user_approved_processes_event() {
551            let hook = create_hook(
552                MockFormatter::new().with_input(Some("file: test.txt")),
553                Verbosity::Normal,
554            );
555            // User approved tools show the approval badge
556            hook.on_event(&tool_started_event(
557                "read_file",
558                ToolApprovalStatus::UserApproved,
559            ));
560        }
561
562        #[test]
563        fn tool_started_short_tool_auto_approved_skipped() {
564            let hook = create_hook(
565                MockFormatter::new().with_input(Some("input")),
566                Verbosity::Normal,
567            );
568            // Short tools that are auto-approved should be skipped
569            hook.on_event(&tool_started_event(
570                "read_file",
571                ToolApprovalStatus::AutoApproved,
572            ));
573        }
574
575        #[test]
576        fn tool_started_verbose_always_processes() {
577            let hook = create_hook(
578                MockFormatter::new().with_input(Some("any input")),
579                Verbosity::Verbose,
580            );
581            // Verbose mode processes everything
582            hook.on_event(&tool_started_event(
583                "any_tool",
584                ToolApprovalStatus::AutoApproved,
585            ));
586        }
587
588        #[test]
589        fn tool_started_with_none_formatted_and_not_approved_skips() {
590            let hook = create_hook(MockFormatter::new().with_input(None), Verbosity::Verbose);
591            // If formatter returns None AND not user approved, should skip printing body
592            hook.on_event(&tool_started_event(
593                "search",
594                ToolApprovalStatus::AutoApproved,
595            ));
596        }
597
598        // Tests for ToolCompleted event handling
599        #[test]
600        fn tool_completed_quiet_mode_prints_minimal() {
601            let hook = create_hook(
602                MockFormatter::new().with_output(Some("output")),
603                Verbosity::Quiet,
604            );
605            // Quiet mode prints just the checkmark
606            hook.on_event(&tool_completed_event("read_file"));
607        }
608
609        #[test]
610        fn tool_completed_normal_mode_with_output() {
611            let hook = create_hook(
612                MockFormatter::new().with_output(Some("file contents here")),
613                Verbosity::Normal,
614            );
615            hook.on_event(&tool_completed_event("read_file"));
616        }
617
618        #[test]
619        fn tool_completed_normal_mode_no_output() {
620            let hook = create_hook(MockFormatter::new().with_output(None), Verbosity::Normal);
621            // Should print "(completed)" when no output
622            hook.on_event(&tool_completed_event("read_file"));
623        }
624
625        #[test]
626        fn tool_completed_verbose_mode() {
627            let hook = create_hook(
628                MockFormatter::new().with_output(Some("detailed output")),
629                Verbosity::Verbose,
630            );
631            hook.on_event(&tool_completed_event("any_tool"));
632        }
633
634        #[test]
635        fn tool_completed_dimmed_tool_output() {
636            let hook = create_hook(
637                MockFormatter::new().with_output(Some("process output")),
638                Verbosity::Normal,
639            );
640            // start_process is a dimmed tool
641            hook.on_event(&tool_completed_event("start_process"));
642        }
643
644        // Tests for ToolFailed event handling
645        #[test]
646        fn tool_failed_prints_error() {
647            let hook = create_hook(MockFormatter::new(), Verbosity::Normal);
648            hook.on_event(&tool_failed_event("read_file", "File not found"));
649        }
650
651        #[test]
652        fn tool_failed_quiet_mode_still_prints() {
653            let hook = create_hook(MockFormatter::new(), Verbosity::Quiet);
654            // Errors should always be visible, even in quiet mode
655            hook.on_event(&tool_failed_event("read_file", "Permission denied"));
656        }
657
658        // Tests for other event types
659        #[test]
660        fn other_events_are_ignored() {
661            let hook = create_hook(MockFormatter::new(), Verbosity::Normal);
662            // These events should be silently ignored (not Tool* events)
663            hook.on_event(&AgentEvent::RunStarted {
664                input: "test".to_string(),
665                timestamp: Instant::now(),
666            });
667            hook.on_event(&AgentEvent::RunCompleted {
668                output: "done".to_string(),
669                duration: std::time::Duration::from_secs(1),
670            });
671        }
672
673        // Test that hook implements AgentHook trait
674        #[test]
675        fn hook_implements_agent_hook() {
676            let hook = create_hook(MockFormatter::new(), Verbosity::Normal);
677            // This compiles because PresentationHook<MockFormatter> implements AgentHook
678            let _: &dyn AgentHook = &hook;
679        }
680    }
681}