Skip to main content

phi_agent/render/
terminal.rs

1use std::io::{self, Write};
2
3use agent_base::{AgentResult, RuntimeEvent, PlanStepStatus};
4
5use crate::render::EventRenderer;
6
7/// Rich terminal renderer: colors, emoji, formatted output.
8pub struct TerminalRenderer {
9    show_thinking: bool,
10    show_tool_args: bool,
11    color: bool,
12    writer: Box<dyn Write + Send>,
13    tool_call_count: u32,
14    turn_start: Option<std::time::Instant>,
15    last_assistant_text: String,
16    last_was_thought: bool,
17}
18
19impl TerminalRenderer {
20    pub fn new(show_thinking: bool, show_tool_args: bool, color: bool, writer: Box<dyn Write + Send>) -> Self {
21        Self {
22            show_thinking,
23            show_tool_args,
24            color,
25            writer,
26            tool_call_count: 0,
27            turn_start: None,
28            last_assistant_text: String::new(),
29            last_was_thought: false,
30        }
31    }
32
33    pub fn stdout(show_thinking: bool, show_tool_args: bool, color: bool) -> Self {
34        Self::new(show_thinking, show_tool_args, color, Box::new(io::stdout()))
35    }
36
37    fn green(&self, s: &str) -> String {
38        if self.color { format!("\x1b[32m{}\x1b[0m", s) } else { s.to_string() }
39    }
40
41    fn dim(&self, s: &str) -> String {
42        if self.color { format!("\x1b[2m{}\x1b[0m", s) } else { s.to_string() }
43    }
44
45    fn bold(&self, s: &str) -> String {
46        if self.color { format!("\x1b[1m{}\x1b[0m", s) } else { s.to_string() }
47    }
48
49    fn yellow(&self, s: &str) -> String {
50        if self.color { format!("\x1b[33m{}\x1b[0m", s) } else { s.to_string() }
51    }
52
53    fn subtle(&self, s: &str) -> String {
54        if self.color { format!("\x1b[90m{}\x1b[0m", s) } else { s.to_string() }
55    }
56
57    fn write_line(&mut self, s: &str) -> AgentResult<()> {
58        writeln!(self.writer, "{}", s)
59            .map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
60        self.writer.flush()
61            .map_err(|e| agent_base::AgentError::internal(format!("flush error: {e}")))?;
62        Ok(())
63    }
64
65    /// Write without newline — for streaming text fragments
66    fn write_text(&mut self, s: &str) -> AgentResult<()> {
67        write!(self.writer, "{}", s)
68            .map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
69        self.writer.flush()
70            .map_err(|e| agent_base::AgentError::internal(format!("flush error: {e}")))?;
71        Ok(())
72    }
73}
74
75impl EventRenderer for TerminalRenderer {
76    fn render(&mut self, event: RuntimeEvent) -> AgentResult<()> {
77        if self.turn_start.is_none() {
78            self.turn_start = Some(std::time::Instant::now());
79        }
80
81        match &event {
82            RuntimeEvent::ThoughtDelta { text, .. } => {
83                if self.show_thinking {
84                    self.write_text(&self.dim(text))?;
85                }
86                self.last_was_thought = true;
87            }
88            RuntimeEvent::TextDelta { text, .. } => {
89                if self.last_was_thought {
90                    let _ = writeln!(self.writer);
91                    self.last_was_thought = false;
92                }
93                self.last_assistant_text.push_str(text);
94                self.write_text(text)?;
95            }
96            RuntimeEvent::ToolCallStarted { tool_name, args_json, .. } => {
97                self.last_was_thought = false;
98                self.tool_call_count += 1;
99                if self.show_tool_args {
100                    self.write_line(&format!(
101                        "\n{} {} {}",
102                        self.bold("\u{1F527}"),
103                        self.green(tool_name),
104                        self.dim(args_json),
105                    ))?;
106                } else {
107                    self.write_line(&format!(
108                        "\n{} {}",
109                        self.bold("\u{1F527}"),
110                        self.green(tool_name),
111                    ))?;
112                }
113            }
114            RuntimeEvent::ToolCallFinished { tool_name: _, summary, .. } => {
115                let summary_short: String = if summary.chars().count() > 500 {
116                    let truncated: String = summary.chars().take(500).collect();
117                    format!("{}...", truncated)
118                } else {
119                    summary.clone()
120                };
121                self.write_line(&format!("   {} {}", self.dim("→"), self.dim(&summary_short)))?;
122                // Add a blank line after tool completion for readability
123                let _ = writeln!(self.writer);
124            }
125            RuntimeEvent::AwaitingApproval { request, .. } => {
126                self.write_line(&format!(
127                    "\n⚠️  {} [{}] — {}",
128                    request.title,
129                    format!("{:?}", request.risk_level),
130                    request.message,
131                ))?;
132            }
133            RuntimeEvent::PlanUpdated { explanation, plan, .. } => {
134                self.write_line(&format!("\n\u{1F4CB} {}", self.bold("Plan Update")))?;
135                self.write_line(&format!(
136                    "   {}",
137                    self.dim(explanation.as_deref().unwrap_or(""))
138                ))?;
139                for item in plan {
140                    let icon = match item.status {
141                        PlanStepStatus::Completed => "✅",
142                        PlanStepStatus::InProgress => "\u{1F504}",
143                        PlanStepStatus::Pending => "⏳",
144                    };
145                    self.write_line(&format!("   {} {}", icon, item.step))?;
146                }
147                let _ = writeln!(self.writer);
148            }
149            RuntimeEvent::RunCancelled { .. } => {
150                self.write_line(&format!("\n{} Cancelled", self.yellow("⚠")))?;
151            }
152            RuntimeEvent::RunFinished { .. } => {}
153            RuntimeEvent::UserEvent { .. } => {}
154            RuntimeEvent::Checkpoint { .. } => {}
155        }
156
157        Ok(())
158    }
159
160    fn finish_turn(&mut self) -> AgentResult<()> {
161        let duration_ms = self
162            .turn_start
163            .map(|s| s.elapsed().as_millis() as u64)
164            .unwrap_or(0);
165
166        let duration_str = if duration_ms >= 1000 {
167            format!("{:.1}s", duration_ms as f64 / 1000.0)
168        } else {
169            format!("{}ms", duration_ms)
170        };
171
172        writeln!(
173            self.writer,
174            "\n{}",
175            self.subtle(&format!("· {} elapsed · {} tool call(s)", duration_str, self.tool_call_count)),
176        )
177        .map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
178
179        self.tool_call_count = 0;
180        self.turn_start = None;
181        self.last_assistant_text.clear();
182        self.last_was_thought = false;
183
184        Ok(())
185    }
186}