phi_agent/render/
terminal.rs1use std::io::{self, Write};
2
3use agent_base::{AgentResult, RuntimeEvent, PlanStepStatus};
4
5use crate::render::EventRenderer;
6
7pub 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 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 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}