1use super::commands::Verbosity;
4use super::formatter::ToolFormatter;
5use mixtape_core::{Agent, AgentEvent, AgentHook, Display, ToolApprovalStatus};
6use std::sync::{Arc, Mutex};
7
8pub 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 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 if formatted.trim().is_empty() {
124 return None;
125 }
126 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 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 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 #[test]
516 fn hook_can_be_created_with_mock_formatter() {
517 let hook = create_hook(MockFormatter::new(), Verbosity::Normal);
518 assert!(Arc::strong_count(&hook.formatter) >= 1);
520 }
521
522 #[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 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 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 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 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 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 hook.on_event(&tool_started_event(
593 "search",
594 ToolApprovalStatus::AutoApproved,
595 ));
596 }
597
598 #[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 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 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 hook.on_event(&tool_completed_event("start_process"));
642 }
643
644 #[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 hook.on_event(&tool_failed_event("read_file", "Permission denied"));
656 }
657
658 #[test]
660 fn other_events_are_ignored() {
661 let hook = create_hook(MockFormatter::new(), Verbosity::Normal);
662 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]
675 fn hook_implements_agent_hook() {
676 let hook = create_hook(MockFormatter::new(), Verbosity::Normal);
677 let _: &dyn AgentHook = &hook;
679 }
680 }
681}