Skip to main content

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};
6use std::collections::VecDeque;
7use std::sync::{Arc, Mutex};
8
9const BOX_WIDTH: usize = 80;
10
11/// Queue for tool events that need to be printed
12pub type EventQueue = Arc<Mutex<VecDeque<AgentEvent>>>;
13
14/// Create a new event queue
15pub fn new_event_queue() -> EventQueue {
16    Arc::new(Mutex::new(VecDeque::new()))
17}
18
19/// Hook that queues tool events for later presentation
20///
21/// Events are queued rather than printed immediately, allowing the caller
22/// to control when output appears (e.g., not during permission prompts).
23pub struct PresentationHook {
24    queue: EventQueue,
25}
26
27impl PresentationHook {
28    pub fn new(queue: EventQueue) -> Self {
29        Self { queue }
30    }
31}
32
33impl AgentHook for PresentationHook {
34    fn on_event(&self, event: &AgentEvent) {
35        // Only queue tool-related events
36        match event {
37            AgentEvent::ToolRequested { .. }
38            | AgentEvent::ToolExecuting { .. }
39            | AgentEvent::ToolCompleted { .. }
40            | AgentEvent::ToolFailed { .. } => {
41                self.queue.lock().unwrap().push_back(event.clone());
42            }
43            _ => {}
44        }
45    }
46}
47
48/// Presenter that formats and prints queued events
49pub struct EventPresenter<F: ToolFormatter = Agent> {
50    formatter: Arc<F>,
51    verbosity: Arc<Mutex<Verbosity>>,
52    queue: EventQueue,
53}
54
55impl<F: ToolFormatter> EventPresenter<F> {
56    pub fn new(formatter: Arc<F>, verbosity: Arc<Mutex<Verbosity>>, queue: EventQueue) -> Self {
57        Self {
58            formatter,
59            verbosity,
60            queue,
61        }
62    }
63
64    /// Drain and print all queued events
65    pub fn flush(&self) {
66        let mut queue = self.queue.lock().unwrap();
67        while let Some(event) = queue.pop_front() {
68            self.print_event(&event);
69        }
70    }
71
72    fn print_event(&self, event: &AgentEvent) {
73        match event {
74            AgentEvent::ToolRequested { name, input, .. } => {
75                let verbosity = *self.verbosity.lock().unwrap();
76                let formatted = self
77                    .formatter
78                    .format_tool_input(name, input, Display::Cli)
79                    .and_then(|formatted| format_tool_input(name, &formatted, verbosity));
80
81                print_tool_header(name);
82                if let Some(output) = formatted {
83                    for line in output.lines() {
84                        println!("│  {}", line);
85                    }
86                }
87            }
88            AgentEvent::ToolExecuting { .. } => {
89                // Optional: could show spinner for long-running tools
90            }
91            AgentEvent::ToolCompleted { name, output, .. } => {
92                let verbosity = *self.verbosity.lock().unwrap();
93                if verbosity == Verbosity::Quiet {
94                    print_result_separator();
95                    println!("│  \x1b[32m✓\x1b[0m");
96                    print_tool_footer(name);
97                    return;
98                }
99                print_result_separator();
100
101                if let Some(formatted) =
102                    self.formatter
103                        .format_tool_output(name, output, Display::Cli)
104                {
105                    if let Some(output) = format_tool_output(name, &formatted, verbosity) {
106                        for line in output.lines() {
107                            println!("│  {}", line);
108                        }
109                    } else {
110                        println!("│  \x1b[2m(no output)\x1b[0m");
111                    }
112                } else {
113                    println!("│  \x1b[2m(no output)\x1b[0m");
114                }
115                print_tool_footer(name);
116            }
117            AgentEvent::ToolFailed { name, error, .. } => {
118                print_result_separator();
119                println!("│  \x1b[31m{}\x1b[0m", error);
120                print_tool_footer(name);
121            }
122            _ => {}
123        }
124    }
125}
126
127fn format_tool_input(tool_name: &str, formatted: &str, verbosity: Verbosity) -> Option<String> {
128    if verbosity == Verbosity::Quiet {
129        return None;
130    }
131    if verbosity == Verbosity::Verbose {
132        return Some(formatted.to_string());
133    }
134    if tool_is_noisy(tool_name) {
135        return None;
136    }
137    Some(formatted.to_string())
138}
139
140fn format_tool_output(tool_name: &str, formatted: &str, verbosity: Verbosity) -> Option<String> {
141    if verbosity == Verbosity::Quiet {
142        return None;
143    }
144    if verbosity == Verbosity::Verbose {
145        return Some(formatted.to_string());
146    }
147    if formatted.trim().is_empty() {
148        return None;
149    }
150    let output = if tool_is_dimmed(tool_name) {
151        dim_text(formatted)
152    } else {
153        formatted.to_string()
154    };
155    Some(output)
156}
157
158fn tool_is_dimmed(tool_name: &str) -> bool {
159    matches!(
160        tool_name,
161        "start_process" | "read_process_output" | "interact_with_process"
162    )
163}
164
165fn tool_is_noisy(tool_name: &str) -> bool {
166    matches!(
167        tool_name,
168        "list_directory" | "search" | "list_processes" | "list_sessions"
169    )
170}
171
172fn dim_text(text: &str) -> String {
173    format!("\x1b[2m{}\x1b[0m", text)
174}
175
176/// Print tool header: ┌─ 🛠️  name ───...───┐
177pub fn print_tool_header(name: &str) {
178    let prefix = format!("┌─ 🛠️  {} ", name);
179    let prefix_display_len = 6 + name.len() + 1; // ┌─ + space + emoji(2) + 2 spaces + name + space
180    let fill = BOX_WIDTH.saturating_sub(prefix_display_len + 1);
181    println!("\n{}{}┐", prefix, "─".repeat(fill));
182    println!("│");
183}
184
185/// Print tool footer: └───...─── name ─┘
186pub fn print_tool_footer(name: &str) {
187    println!("│");
188    let suffix = format!(" {} ─┘", name);
189    let fill = BOX_WIDTH.saturating_sub(suffix.len() + 1);
190    println!("└{}{}", "─".repeat(fill), suffix);
191}
192
193/// Print result separator with blank lines
194pub fn print_result_separator() {
195    println!("│");
196    println!("├─ Result");
197    println!("│");
198}
199
200pub fn indent_lines(text: &str) -> String {
201    if text.is_empty() {
202        return String::new();
203    }
204    let mut lines = text.lines();
205    let Some(first) = lines.next() else {
206        return String::new();
207    };
208    let mut output = format!("  └ {}", first);
209    for line in lines {
210        output.push_str(&format!("\n    {}", line));
211    }
212    output
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    mod indent_lines_tests {
220        use super::*;
221
222        #[test]
223        fn empty_string_returns_empty() {
224            assert_eq!(indent_lines(""), "");
225        }
226
227        #[test]
228        fn single_line_gets_prefix() {
229            assert_eq!(indent_lines("hello"), "  └ hello");
230        }
231
232        #[test]
233        fn multiline_indents_continuation() {
234            let input = "line1\nline2\nline3";
235            let expected = "  └ line1\n    line2\n    line3";
236            assert_eq!(indent_lines(input), expected);
237        }
238
239        #[test]
240        fn handles_empty_lines_in_middle() {
241            let input = "line1\n\nline3";
242            let expected = "  └ line1\n    \n    line3";
243            assert_eq!(indent_lines(input), expected);
244        }
245
246        #[test]
247        fn preserves_existing_indentation() {
248            let input = "func() {\n    body\n}";
249            let expected = "  └ func() {\n        body\n    }";
250            assert_eq!(indent_lines(input), expected);
251        }
252    }
253
254    mod tool_classification_tests {
255        use super::*;
256
257        #[test]
258        fn dimmed_tools_identified() {
259            assert!(tool_is_dimmed("start_process"));
260            assert!(tool_is_dimmed("read_process_output"));
261            assert!(tool_is_dimmed("interact_with_process"));
262        }
263
264        #[test]
265        fn non_dimmed_tools_not_flagged() {
266            assert!(!tool_is_dimmed("read_file"));
267            assert!(!tool_is_dimmed("search"));
268            assert!(!tool_is_dimmed("fetch"));
269        }
270
271        #[test]
272        fn noisy_tools_identified() {
273            assert!(tool_is_noisy("list_directory"));
274            assert!(tool_is_noisy("search"));
275            assert!(tool_is_noisy("list_processes"));
276            assert!(tool_is_noisy("list_sessions"));
277        }
278
279        #[test]
280        fn non_noisy_tools_not_flagged() {
281            assert!(!tool_is_noisy("read_file"));
282            assert!(!tool_is_noisy("fetch"));
283            assert!(!tool_is_noisy("start_process"));
284        }
285    }
286
287    mod dim_text_tests {
288        use super::*;
289
290        #[test]
291        fn wraps_text_with_ansi_codes() {
292            assert_eq!(dim_text("hello"), "\x1b[2mhello\x1b[0m");
293        }
294
295        #[test]
296        fn handles_empty_string() {
297            assert_eq!(dim_text(""), "\x1b[2m\x1b[0m");
298        }
299
300        #[test]
301        fn handles_multiline_text() {
302            assert_eq!(dim_text("line1\nline2"), "\x1b[2mline1\nline2\x1b[0m");
303        }
304    }
305
306    mod format_tool_input_tests {
307        use super::*;
308
309        #[test]
310        fn quiet_returns_none() {
311            assert!(format_tool_input("any_tool", "content", Verbosity::Quiet).is_none());
312        }
313
314        #[test]
315        fn verbose_always_returns_content() {
316            assert_eq!(
317                format_tool_input("list_directory", "content", Verbosity::Verbose),
318                Some("content".to_string())
319            );
320        }
321
322        #[test]
323        fn normal_filters_noisy_tools() {
324            assert!(format_tool_input("list_directory", "content", Verbosity::Normal).is_none());
325        }
326
327        #[test]
328        fn normal_shows_non_noisy_tools() {
329            assert_eq!(
330                format_tool_input("read_file", "content", Verbosity::Normal),
331                Some("content".to_string())
332            );
333        }
334    }
335
336    mod format_tool_output_tests {
337        use super::*;
338
339        #[test]
340        fn quiet_returns_none() {
341            assert!(format_tool_output("any_tool", "content", Verbosity::Quiet).is_none());
342        }
343
344        #[test]
345        fn verbose_returns_content_as_is() {
346            assert_eq!(
347                format_tool_output("start_process", "output", Verbosity::Verbose),
348                Some("output".to_string())
349            );
350        }
351
352        #[test]
353        fn normal_dims_dimmed_tools() {
354            assert_eq!(
355                format_tool_output("start_process", "output", Verbosity::Normal),
356                Some("\x1b[2moutput\x1b[0m".to_string())
357            );
358        }
359
360        #[test]
361        fn normal_does_not_dim_other_tools() {
362            assert_eq!(
363                format_tool_output("read_file", "output", Verbosity::Normal),
364                Some("output".to_string())
365            );
366        }
367
368        #[test]
369        fn empty_output_returns_none() {
370            assert!(format_tool_output("read_file", "", Verbosity::Normal).is_none());
371            assert!(format_tool_output("read_file", "   ", Verbosity::Normal).is_none());
372        }
373
374        #[test]
375        fn whitespace_only_dimmed_returns_none() {
376            assert!(format_tool_output("start_process", "  ", Verbosity::Normal).is_none());
377        }
378    }
379
380    mod presentation_hook_tests {
381        use super::*;
382        use mixtape_core::ToolResult;
383        use serde_json::json;
384        use std::time::Instant;
385
386        fn tool_requested_event(name: &str) -> AgentEvent {
387            AgentEvent::ToolRequested {
388                tool_use_id: "test-id".to_string(),
389                name: name.to_string(),
390                input: json!({"query": "test"}),
391            }
392        }
393
394        fn tool_completed_event(name: &str) -> AgentEvent {
395            AgentEvent::ToolCompleted {
396                tool_use_id: "test-id".to_string(),
397                name: name.to_string(),
398                output: ToolResult::Text("result".to_string()),
399                duration: std::time::Duration::from_millis(100),
400            }
401        }
402
403        #[test]
404        fn hook_queues_tool_events() {
405            let queue = new_event_queue();
406            let hook = PresentationHook::new(Arc::clone(&queue));
407
408            hook.on_event(&tool_requested_event("test_tool"));
409            hook.on_event(&tool_completed_event("test_tool"));
410
411            assert_eq!(queue.lock().unwrap().len(), 2);
412        }
413
414        #[test]
415        fn hook_ignores_non_tool_events() {
416            let queue = new_event_queue();
417            let hook = PresentationHook::new(Arc::clone(&queue));
418
419            hook.on_event(&AgentEvent::RunStarted {
420                input: "test".to_string(),
421                timestamp: Instant::now(),
422            });
423
424            assert_eq!(queue.lock().unwrap().len(), 0);
425        }
426
427        #[test]
428        fn hook_implements_agent_hook() {
429            let queue = new_event_queue();
430            let hook = PresentationHook::new(queue);
431            let _: &dyn AgentHook = &hook;
432        }
433    }
434}