Skip to main content

phi_agent/render/
terminal.rs

1#![allow(missing_docs)]
2
3use std::io::{self, Write};
4
5use agent_base::{AgentResult, PlanStepStatus, RuntimeEvent};
6
7use crate::render::EventRenderer;
8
9/// Rich terminal renderer — colors, emoji, formatted output.
10///
11/// Streams AI responses in real-time, displays tool calls with icons, and
12/// shows turn summaries including duration and tool call count.
13pub struct TerminalRenderer {
14    show_thinking: bool,
15    show_tool_args: bool,
16    color: bool,
17    writer: Box<dyn Write + Send>,
18    tool_call_count: u32,
19    turn_start: Option<std::time::Instant>,
20    last_assistant_text: String,
21    last_was_thought: bool,
22}
23
24impl TerminalRenderer {
25    /// Create a new terminal renderer.
26    ///
27    /// - `show_thinking` — display the LLM's chain-of-thought
28    /// - `show_tool_args` — display tool call arguments inline
29    /// - `color` — enable ANSI color codes
30    /// - `writer` — output destination (usually stdout, can be a WebSocket, etc.)
31    pub fn new(show_thinking: bool, show_tool_args: bool, color: bool, writer: Box<dyn Write + Send>) -> Self {
32        Self {
33            show_thinking,
34            show_tool_args,
35            color,
36            writer,
37            tool_call_count: 0,
38            turn_start: None,
39            last_assistant_text: String::new(),
40            last_was_thought: false,
41        }
42    }
43
44    pub fn stdout(show_thinking: bool, show_tool_args: bool, color: bool) -> Self {
45        Self::new(show_thinking, show_tool_args, color, Box::new(io::stdout()))
46    }
47
48    fn green(&self, s: &str) -> String {
49        if self.color { format!("\x1b[32m{}\x1b[0m", s) } else { s.to_string() }
50    }
51
52    fn dim(&self, s: &str) -> String {
53        if self.color { format!("\x1b[2m{}\x1b[0m", s) } else { s.to_string() }
54    }
55
56    fn bold(&self, s: &str) -> String {
57        if self.color { format!("\x1b[1m{}\x1b[0m", s) } else { s.to_string() }
58    }
59
60    fn yellow(&self, s: &str) -> String {
61        if self.color { format!("\x1b[33m{}\x1b[0m", s) } else { s.to_string() }
62    }
63
64    fn subtle(&self, s: &str) -> String {
65        if self.color { format!("\x1b[90m{}\x1b[0m", s) } else { s.to_string() }
66    }
67
68    fn write_line(&mut self, s: &str) -> AgentResult<()> {
69        writeln!(self.writer, "{}", s).map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
70        self.writer.flush().map_err(|e| agent_base::AgentError::internal(format!("flush error: {e}")))?;
71        Ok(())
72    }
73
74    /// Write without newline — for streaming text fragments
75    fn write_text(&mut self, s: &str) -> AgentResult<()> {
76        write!(self.writer, "{}", s).map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
77        self.writer.flush().map_err(|e| agent_base::AgentError::internal(format!("flush error: {e}")))?;
78        Ok(())
79    }
80}
81
82impl EventRenderer for TerminalRenderer {
83    fn render(&mut self, event: RuntimeEvent) -> AgentResult<()> {
84        if self.turn_start.is_none() {
85            self.turn_start = Some(std::time::Instant::now());
86        }
87
88        // Sub-agent events (agent_id set) get a `[path]` prefix and must not
89        // pollute the parent's assistant-text buffer.
90        let agent_prefix = event.agent_id().map(|id| self.subtle(&format!("[{}]", id)));
91        let is_subagent = agent_prefix.is_some();
92
93        match &event {
94            RuntimeEvent::ThoughtDelta { text, .. } => {
95                if self.show_thinking {
96                    match &agent_prefix {
97                        Some(prefix) => self.write_text(&format!("{} {}", prefix, self.dim(text)))?,
98                        None => self.write_text(&self.dim(text))?,
99                    }
100                }
101                self.last_was_thought = true;
102            },
103            RuntimeEvent::TextDelta { text, .. } => {
104                if self.last_was_thought {
105                    let _ = writeln!(self.writer);
106                    self.last_was_thought = false;
107                }
108                if !is_subagent {
109                    self.last_assistant_text.push_str(text);
110                }
111                match &agent_prefix {
112                    Some(prefix) => self.write_text(&format!("{} {}", prefix, text))?,
113                    None => self.write_text(text)?,
114                }
115            },
116            RuntimeEvent::ToolCallStarted { tool_name, args_json, .. } => {
117                self.last_was_thought = false;
118                self.tool_call_count += 1;
119                let head = match &agent_prefix {
120                    Some(prefix) => format!("{} {}", prefix, self.bold("\u{1F527}")),
121                    None => self.bold("\u{1F527}"),
122                };
123                if self.show_tool_args {
124                    self.write_line(&format!("\n{} {} {}", head, self.green(tool_name), self.dim(args_json)))?;
125                } else {
126                    self.write_line(&format!("\n{} {}", head, self.green(tool_name)))?;
127                }
128            },
129            RuntimeEvent::ToolCallFinished { tool_name: _, summary, .. } => {
130                let summary_short: String = if summary.chars().count() > 500 {
131                    let truncated: String = summary.chars().take(500).collect();
132                    format!("{}...", truncated)
133                } else {
134                    summary.clone()
135                };
136                match &agent_prefix {
137                    Some(prefix) => {
138                        self.write_line(&format!("{}   {} {}", prefix, self.dim("→"), self.dim(&summary_short)))?
139                    },
140                    None => self.write_line(&format!("   {} {}", self.dim("→"), self.dim(&summary_short)))?,
141                }
142                // Add a blank line after tool completion for readability
143                let _ = writeln!(self.writer);
144            },
145            RuntimeEvent::AwaitingApproval { request, .. } => {
146                self.write_line(&format!("\n⚠️  {} [{:?}] — {}", request.title, request.risk_level, request.message,))?;
147            },
148            RuntimeEvent::PlanUpdated { explanation, plan, .. } => {
149                self.write_line(&format!("\n\u{1F4CB} {}", self.bold("Plan Update")))?;
150                self.write_line(&format!("   {}", self.dim(explanation.as_deref().unwrap_or(""))))?;
151                for item in plan {
152                    let icon = match item.status {
153                        PlanStepStatus::Completed => "✅",
154                        PlanStepStatus::InProgress => "\u{1F504}",
155                        PlanStepStatus::Pending => "⏳",
156                    };
157                    self.write_line(&format!("   {} {}", icon, item.step))?;
158                }
159                let _ = writeln!(self.writer);
160            },
161            RuntimeEvent::RunCancelled { .. } => match &agent_prefix {
162                Some(prefix) => self.write_line(&format!("{} {} Cancelled", prefix, self.yellow("⚠")))?,
163                None => self.write_line(&format!("\n{} Cancelled", self.yellow("⚠")))?,
164            },
165            RuntimeEvent::RunFinished { .. } => {},
166            RuntimeEvent::UserEvent { .. } => {},
167            RuntimeEvent::Checkpoint { .. } => {},
168        }
169
170        Ok(())
171    }
172
173    fn finish_turn(&mut self) -> AgentResult<()> {
174        let duration_ms = self.turn_start.map(|s| s.elapsed().as_millis() as u64).unwrap_or(0);
175
176        let duration_str = if duration_ms >= 1000 {
177            format!("{:.1}s", duration_ms as f64 / 1000.0)
178        } else {
179            format!("{}ms", duration_ms)
180        };
181
182        writeln!(
183            self.writer,
184            "\n{}",
185            self.subtle(&format!("· {} elapsed · {} tool call(s)", duration_str, self.tool_call_count)),
186        )
187        .map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
188
189        self.tool_call_count = 0;
190        self.turn_start = None;
191        self.last_assistant_text.clear();
192        self.last_was_thought = false;
193
194        Ok(())
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use agent_base::{ApprovalRequest, PlanItem, PlanStepStatus, RiskLevel, SessionId, UserEvent};
202    use std::io::Write;
203    use std::sync::{Arc, Mutex};
204
205    /// A Write impl backed by shared memory, for testing renderers.
206    struct SharedWriter {
207        inner: Arc<Mutex<Vec<u8>>>,
208    }
209
210    impl Write for SharedWriter {
211        fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
212            self.inner.lock().unwrap().extend_from_slice(data);
213            Ok(data.len())
214        }
215        fn flush(&mut self) -> std::io::Result<()> {
216            Ok(())
217        }
218    }
219
220    impl SharedWriter {
221        fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
222            let inner = Arc::new(Mutex::new(Vec::new()));
223            (Self { inner: inner.clone() }, inner)
224        }
225    }
226
227    fn session_id() -> SessionId {
228        SessionId { id: 1, external_id: None }
229    }
230
231    fn render_one(show_thinking: bool, show_tool_args: bool, color: bool, event: RuntimeEvent) -> String {
232        let (writer, buf) = SharedWriter::new();
233        let mut r = TerminalRenderer::new(show_thinking, show_tool_args, color, Box::new(writer));
234        r.render(event).unwrap();
235        drop(r);
236        String::from_utf8(buf.lock().unwrap().clone()).unwrap()
237    }
238
239    fn render_events(show_thinking: bool, show_tool_args: bool, color: bool, events: &[RuntimeEvent]) -> String {
240        let (writer, buf) = SharedWriter::new();
241        let mut r = TerminalRenderer::new(show_thinking, show_tool_args, color, Box::new(writer));
242        for e in events {
243            r.render(e.clone()).unwrap();
244        }
245        r.finish_turn().unwrap();
246        drop(r);
247        String::from_utf8(buf.lock().unwrap().clone()).unwrap()
248    }
249
250    // ── Color tests ──
251
252    #[test]
253    fn test_color_methods_enabled() {
254        let (writer, _buf) = SharedWriter::new();
255        let r = TerminalRenderer::new(true, true, true, Box::new(writer));
256        assert!(r.green("hello").contains("\x1b[32m"));
257        assert!(r.dim("hello").contains("\x1b[2m"));
258        assert!(r.bold("hello").contains("\x1b[1m"));
259        assert!(r.yellow("hello").contains("\x1b[33m"));
260        assert!(r.subtle("hello").contains("\x1b[90m"));
261        assert!(r.green("hello").ends_with("\x1b[0m"));
262    }
263
264    #[test]
265    fn test_color_methods_disabled() {
266        let (writer, _buf) = SharedWriter::new();
267        let r = TerminalRenderer::new(true, true, false, Box::new(writer));
268        assert!(!r.green("hello").contains('\x1b'));
269        assert_eq!(r.green("hello"), "hello");
270        assert_eq!(r.dim("x"), "x");
271        assert_eq!(r.bold("x"), "x");
272        assert_eq!(r.yellow("x"), "x");
273        assert_eq!(r.subtle("x"), "x");
274    }
275
276    // ── Event rendering tests ──
277
278    #[test]
279    fn test_render_text_delta() {
280        let out = render_one(
281            true,
282            true,
283            true,
284            RuntimeEvent::TextDelta {
285                session_id: session_id(),
286                text: "hello world".into(),
287                agent_id: None,
288                trace_id: None,
289            },
290        );
291        assert!(out.contains("hello world"));
292    }
293
294    #[test]
295    fn test_render_thought_delta_shown() {
296        let out = render_one(
297            true,
298            true,
299            true,
300            RuntimeEvent::ThoughtDelta {
301                session_id: session_id(),
302                text: "thinking...".into(),
303                agent_id: None,
304                trace_id: None,
305            },
306        );
307        assert!(out.contains("thinking..."));
308    }
309
310    #[test]
311    fn test_render_thought_delta_hidden() {
312        let out = render_one(
313            false,
314            true,
315            true,
316            RuntimeEvent::ThoughtDelta {
317                session_id: session_id(),
318                text: "secret thought".into(),
319                agent_id: None,
320                trace_id: None,
321            },
322        );
323        assert!(!out.contains("secret thought"));
324    }
325
326    #[test]
327    fn test_render_tool_call_started_with_args() {
328        let out = render_one(
329            true,
330            true,
331            true,
332            RuntimeEvent::ToolCallStarted {
333                session_id: session_id(),
334                tool_name: "read_file".into(),
335                args_json: r#"{"path":"/tmp/a.txt"}"#.into(),
336                agent_id: None,
337                trace_id: None,
338            },
339        );
340        assert!(out.contains("read_file"));
341        assert!(out.contains("a.txt"));
342    }
343
344    #[test]
345    fn test_render_tool_call_started_without_args() {
346        let out = render_one(
347            true,
348            false,
349            true,
350            RuntimeEvent::ToolCallStarted {
351                session_id: session_id(),
352                tool_name: "read_file".into(),
353                args_json: r#"{"path":"/tmp/a.txt"}"#.into(),
354                agent_id: None,
355                trace_id: None,
356            },
357        );
358        assert!(out.contains("read_file"));
359        assert!(!out.contains("a.txt"));
360    }
361
362    #[test]
363    fn test_render_tool_call_finished_short_summary() {
364        let out = render_one(
365            true,
366            true,
367            true,
368            RuntimeEvent::ToolCallFinished {
369                session_id: session_id(),
370                tool_name: "read_file".into(),
371                summary: "file contents here".into(),
372                agent_id: None,
373                trace_id: None,
374                denied: false,
375            },
376        );
377        assert!(out.contains("file contents here"));
378    }
379
380    #[test]
381    fn test_render_tool_call_finished_truncated() {
382        let long = "x".repeat(600);
383        let out = render_one(
384            true,
385            true,
386            true,
387            RuntimeEvent::ToolCallFinished {
388                session_id: session_id(),
389                tool_name: "read_file".into(),
390                summary: long.clone(),
391                agent_id: None,
392                trace_id: None,
393                denied: false,
394            },
395        );
396        assert!(!out.contains(&long));
397        assert!(out.contains("..."));
398        assert!(out.contains(&"x".repeat(400)));
399    }
400
401    #[test]
402    fn test_render_awaiting_approval() {
403        let out = render_one(
404            true,
405            true,
406            true,
407            RuntimeEvent::AwaitingApproval {
408                session_id: session_id(),
409                request: ApprovalRequest {
410                    title: "Delete file".into(),
411                    message: "This will delete /tmp/important.txt".into(),
412                    action_key: None,
413                    risk_level: RiskLevel::Destructive,
414                    raw: None,
415                },
416                agent_id: None,
417                trace_id: None,
418            },
419        );
420        assert!(out.contains("Delete file"));
421        assert!(out.contains("Destructive"));
422    }
423
424    #[test]
425    fn test_render_plan_updated() {
426        let out = render_one(
427            true,
428            true,
429            true,
430            RuntimeEvent::PlanUpdated {
431                session_id: session_id(),
432                objective: "test plan".into(),
433                explanation: Some("starting work".into()),
434                plan: vec![
435                    PlanItem { step: "Step 1".into(), status: PlanStepStatus::Completed },
436                    PlanItem { step: "Step 2".into(), status: PlanStepStatus::InProgress },
437                    PlanItem { step: "Step 3".into(), status: PlanStepStatus::Pending },
438                ],
439                agent_id: None,
440                trace_id: None,
441            },
442        );
443        assert!(out.contains("Plan Update"));
444        assert!(out.contains("starting work"));
445        assert!(out.contains("✅"));
446        assert!(out.contains("Step 1"));
447        assert!(out.contains("Step 2"));
448        assert!(out.contains("Step 3"));
449    }
450
451    #[test]
452    fn test_render_run_cancelled() {
453        let out = render_one(
454            true,
455            true,
456            true,
457            RuntimeEvent::RunCancelled { session_id: session_id(), agent_id: None, trace_id: None },
458        );
459        assert!(out.contains("Cancelled"));
460    }
461
462    #[test]
463    fn test_render_run_finished_no_output() {
464        let out = render_one(
465            true,
466            true,
467            true,
468            RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None },
469        );
470        assert!(out.is_empty());
471    }
472
473    #[test]
474    fn test_render_user_event_progress_no_output() {
475        let out = render_one(
476            true,
477            true,
478            true,
479            RuntimeEvent::UserEvent {
480                session_id: session_id(),
481                event: UserEvent::Progress { text: "loading...".into() },
482                agent_id: None,
483                trace_id: None,
484            },
485        );
486        assert!(out.is_empty());
487    }
488
489    // ── finish_turn tests ──
490
491    #[test]
492    fn test_finish_turn_contains_duration_and_tool_count() {
493        let out = render_events(
494            true,
495            true,
496            true,
497            &[RuntimeEvent::TextDelta { session_id: session_id(), text: "hi".into(), agent_id: None, trace_id: None }],
498        );
499        assert!(out.contains("elapsed"));
500        assert!(out.contains("tool call"));
501    }
502
503    #[test]
504    fn test_finish_turn_tool_count() {
505        let out = render_events(
506            true,
507            true,
508            true,
509            &[
510                RuntimeEvent::ToolCallStarted {
511                    session_id: session_id(),
512                    tool_name: "a".into(),
513                    args_json: "{}".into(),
514                    agent_id: None,
515                    trace_id: None,
516                },
517                RuntimeEvent::ToolCallStarted {
518                    session_id: session_id(),
519                    tool_name: "b".into(),
520                    args_json: "{}".into(),
521                    agent_id: None,
522                    trace_id: None,
523                },
524                RuntimeEvent::ToolCallStarted {
525                    session_id: session_id(),
526                    tool_name: "c".into(),
527                    args_json: "{}".into(),
528                    agent_id: None,
529                    trace_id: None,
530                },
531            ],
532        );
533        assert!(out.contains("3 tool call"));
534    }
535
536    #[test]
537    fn test_multiple_turns_reset() {
538        let (writer, buf) = SharedWriter::new();
539        {
540            let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
541            r.render(RuntimeEvent::ToolCallStarted {
542                session_id: session_id(),
543                tool_name: "t1".into(),
544                args_json: "{}".into(),
545                agent_id: None,
546                trace_id: None,
547            })
548            .unwrap();
549            r.finish_turn().unwrap();
550            r.render(RuntimeEvent::TextDelta {
551                session_id: session_id(),
552                text: "hello".into(),
553                agent_id: None,
554                trace_id: None,
555            })
556            .unwrap();
557            r.finish_turn().unwrap();
558        }
559        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
560        assert!(out.contains("1 tool call"));
561        assert!(out.contains("0 tool call"));
562    }
563
564    #[test]
565    fn test_thought_to_text_transition_adds_newline() {
566        let (writer, buf) = SharedWriter::new();
567        {
568            let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
569            r.render(RuntimeEvent::ThoughtDelta {
570                session_id: session_id(),
571                text: "hmm".into(),
572                agent_id: None,
573                trace_id: None,
574            })
575            .unwrap();
576            r.render(RuntimeEvent::TextDelta {
577                session_id: session_id(),
578                text: "hello".into(),
579                agent_id: None,
580                trace_id: None,
581            })
582            .unwrap();
583        }
584        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
585        assert!(out.contains("hmm"));
586        assert!(out.contains("hello"));
587    }
588
589    // ── Sub-agent (agent_id) tests ──
590
591    #[test]
592    fn test_render_subagent_text_delta() {
593        let out = render_one(
594            true,
595            true,
596            false,
597            RuntimeEvent::TextDelta {
598                session_id: session_id(),
599                text: "found results".into(),
600                agent_id: Some("root/searcher".into()),
601                trace_id: None,
602            },
603        );
604        assert!(out.contains("[root/searcher]"));
605        assert!(out.contains("found results"));
606    }
607
608    #[test]
609    fn test_render_subagent_tool_call() {
610        let out = render_one(
611            true,
612            true,
613            false,
614            RuntimeEvent::ToolCallStarted {
615                session_id: session_id(),
616                tool_name: "search".into(),
617                args_json: r#"{"q":"test"}"#.into(),
618                agent_id: Some("root/worker".into()),
619                trace_id: None,
620            },
621        );
622        assert!(out.contains("[root/worker]"));
623        assert!(out.contains("search"));
624        assert!(out.contains("test"));
625    }
626
627    #[test]
628    fn test_render_subagent_thought_hidden() {
629        let out = render_one(
630            false,
631            true,
632            false,
633            RuntimeEvent::ThoughtDelta {
634                session_id: session_id(),
635                text: "secret plan".into(),
636                agent_id: Some("root/thinker".into()),
637                trace_id: None,
638            },
639        );
640        assert!(!out.contains("secret plan"));
641    }
642
643    #[test]
644    fn test_render_subagent_run_cancelled() {
645        let out = render_one(
646            true,
647            true,
648            false,
649            RuntimeEvent::RunCancelled {
650                session_id: session_id(),
651                agent_id: Some("root/worker".into()),
652                trace_id: None,
653            },
654        );
655        assert!(out.contains("[root/worker]"));
656        assert!(out.contains("Cancelled"));
657    }
658}