1use 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
11pub type EventQueue = Arc<Mutex<VecDeque<AgentEvent>>>;
13
14pub fn new_event_queue() -> EventQueue {
16 Arc::new(Mutex::new(VecDeque::new()))
17}
18
19pub 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 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
48pub 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 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 }
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
176pub fn print_tool_header(name: &str) {
178 let prefix = format!("┌─ 🛠️ {} ", name);
179 let prefix_display_len = 6 + name.len() + 1; let fill = BOX_WIDTH.saturating_sub(prefix_display_len + 1);
181 println!("\n{}{}┐", prefix, "─".repeat(fill));
182 println!("│");
183}
184
185pub 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
193pub 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}