Skip to main content

phi_agent/render/
terminal.rs

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