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