1use std::io::{self, Write};
2
3use agent_base::{AgentResult, PlanStepStatus, RuntimeEvent};
4
5use crate::render::EventRenderer;
6
7pub 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 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 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 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 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<()> { Ok(()) }
196 }
197
198 impl SharedWriter {
199 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
200 let inner = Arc::new(Mutex::new(Vec::new()));
201 (Self { inner: inner.clone() }, inner)
202 }
203 }
204
205 fn session_id() -> SessionId {
206 SessionId { id: 1, external_id: None }
207 }
208
209 fn render_one(
210 show_thinking: bool,
211 show_tool_args: bool,
212 color: bool,
213 event: RuntimeEvent,
214 ) -> String {
215 let (writer, buf) = SharedWriter::new();
216 let mut r = TerminalRenderer::new(show_thinking, show_tool_args, color, Box::new(writer));
217 r.render(event).unwrap();
218 drop(r);
219 String::from_utf8(buf.lock().unwrap().clone()).unwrap()
220 }
221
222 fn render_events(
223 show_thinking: bool,
224 show_tool_args: bool,
225 color: bool,
226 events: &[RuntimeEvent],
227 ) -> String {
228 let (writer, buf) = SharedWriter::new();
229 let mut r = TerminalRenderer::new(show_thinking, show_tool_args, color, Box::new(writer));
230 for e in events {
231 r.render(e.clone()).unwrap();
232 }
233 r.finish_turn().unwrap();
234 drop(r);
235 String::from_utf8(buf.lock().unwrap().clone()).unwrap()
236 }
237
238 #[test]
241 fn test_color_methods_enabled() {
242 let (writer, _buf) = SharedWriter::new();
243 let r = TerminalRenderer::new(true, true, true, Box::new(writer));
244 assert!(r.green("hello").contains("\x1b[32m"));
245 assert!(r.dim("hello").contains("\x1b[2m"));
246 assert!(r.bold("hello").contains("\x1b[1m"));
247 assert!(r.yellow("hello").contains("\x1b[33m"));
248 assert!(r.subtle("hello").contains("\x1b[90m"));
249 assert!(r.green("hello").ends_with("\x1b[0m"));
250 }
251
252 #[test]
253 fn test_color_methods_disabled() {
254 let (writer, _buf) = SharedWriter::new();
255 let r = TerminalRenderer::new(true, true, false, Box::new(writer));
256 assert!(!r.green("hello").contains('\x1b'));
257 assert_eq!(r.green("hello"), "hello");
258 assert_eq!(r.dim("x"), "x");
259 assert_eq!(r.bold("x"), "x");
260 assert_eq!(r.yellow("x"), "x");
261 assert_eq!(r.subtle("x"), "x");
262 }
263
264 #[test]
267 fn test_render_text_delta() {
268 let out = render_one(true, true, true, RuntimeEvent::TextDelta {
269 session_id: session_id(),
270 text: "hello world".into(),
271 });
272 assert!(out.contains("hello world"));
273 }
274
275 #[test]
276 fn test_render_thought_delta_shown() {
277 let out = render_one(true, true, true, RuntimeEvent::ThoughtDelta {
278 session_id: session_id(),
279 text: "thinking...".into(),
280 });
281 assert!(out.contains("thinking..."));
282 }
283
284 #[test]
285 fn test_render_thought_delta_hidden() {
286 let out = render_one(false, true, true, RuntimeEvent::ThoughtDelta {
287 session_id: session_id(),
288 text: "secret thought".into(),
289 });
290 assert!(!out.contains("secret thought"));
291 }
292
293 #[test]
294 fn test_render_tool_call_started_with_args() {
295 let out = render_one(true, true, true, RuntimeEvent::ToolCallStarted {
296 session_id: session_id(),
297 tool_name: "read_file".into(),
298 args_json: r#"{"path":"/tmp/a.txt"}"#.into(),
299 });
300 assert!(out.contains("read_file"));
301 assert!(out.contains("a.txt"));
302 }
303
304 #[test]
305 fn test_render_tool_call_started_without_args() {
306 let out = render_one(true, false, true, RuntimeEvent::ToolCallStarted {
307 session_id: session_id(),
308 tool_name: "read_file".into(),
309 args_json: r#"{"path":"/tmp/a.txt"}"#.into(),
310 });
311 assert!(out.contains("read_file"));
312 assert!(!out.contains("a.txt"));
313 }
314
315 #[test]
316 fn test_render_tool_call_finished_short_summary() {
317 let out = render_one(true, true, true, RuntimeEvent::ToolCallFinished {
318 session_id: session_id(),
319 tool_name: "read_file".into(),
320 summary: "file contents here".into(),
321 });
322 assert!(out.contains("file contents here"));
323 }
324
325 #[test]
326 fn test_render_tool_call_finished_truncated() {
327 let long = "x".repeat(600);
328 let out = render_one(true, true, true, RuntimeEvent::ToolCallFinished {
329 session_id: session_id(),
330 tool_name: "read_file".into(),
331 summary: long.clone(),
332 });
333 assert!(!out.contains(&long));
334 assert!(out.contains("..."));
335 assert!(out.contains(&"x".repeat(400)));
336 }
337
338 #[test]
339 fn test_render_awaiting_approval() {
340 let out = render_one(true, true, true, RuntimeEvent::AwaitingApproval {
341 session_id: session_id(),
342 request: ApprovalRequest {
343 title: "Delete file".into(),
344 message: "This will delete /tmp/important.txt".into(),
345 action_key: None,
346 risk_level: RiskLevel::Destructive,
347 raw: None,
348 },
349 });
350 assert!(out.contains("Delete file"));
351 assert!(out.contains("Destructive"));
352 }
353
354 #[test]
355 fn test_render_plan_updated() {
356 let out = render_one(true, true, true, RuntimeEvent::PlanUpdated {
357 session_id: session_id(),
358 objective: "test plan".into(),
359 explanation: Some("starting work".into()),
360 plan: vec![
361 PlanItem { step: "Step 1".into(), status: PlanStepStatus::Completed },
362 PlanItem { step: "Step 2".into(), status: PlanStepStatus::InProgress },
363 PlanItem { step: "Step 3".into(), status: PlanStepStatus::Pending },
364 ],
365 });
366 assert!(out.contains("Plan Update"));
367 assert!(out.contains("starting work"));
368 assert!(out.contains("✅"));
369 assert!(out.contains("Step 1"));
370 assert!(out.contains("Step 2"));
371 assert!(out.contains("Step 3"));
372 }
373
374 #[test]
375 fn test_render_run_cancelled() {
376 let out = render_one(true, true, true, RuntimeEvent::RunCancelled {
377 session_id: session_id(),
378 });
379 assert!(out.contains("Cancelled"));
380 }
381
382 #[test]
383 fn test_render_run_finished_no_output() {
384 let out = render_one(true, true, true, RuntimeEvent::RunFinished {
385 session_id: session_id(),
386 });
387 assert!(out.is_empty());
388 }
389
390 #[test]
391 fn test_render_user_event_progress_no_output() {
392 let out = render_one(true, true, true, RuntimeEvent::UserEvent {
393 session_id: session_id(),
394 event: UserEvent::Progress { text: "loading...".into() },
395 });
396 assert!(out.is_empty());
397 }
398
399 #[test]
402 fn test_finish_turn_contains_duration_and_tool_count() {
403 let out = render_events(true, true, true, &[
404 RuntimeEvent::TextDelta { session_id: session_id(), text: "hi".into() },
405 ]);
406 assert!(out.contains("elapsed"));
407 assert!(out.contains("tool call"));
408 }
409
410 #[test]
411 fn test_finish_turn_tool_count() {
412 let out = render_events(true, true, true, &[
413 RuntimeEvent::ToolCallStarted {
414 session_id: session_id(), tool_name: "a".into(), args_json: "{}".into(),
415 },
416 RuntimeEvent::ToolCallStarted {
417 session_id: session_id(), tool_name: "b".into(), args_json: "{}".into(),
418 },
419 RuntimeEvent::ToolCallStarted {
420 session_id: session_id(), tool_name: "c".into(), args_json: "{}".into(),
421 },
422 ]);
423 assert!(out.contains("3 tool call"));
424 }
425
426 #[test]
427 fn test_multiple_turns_reset() {
428 let (writer, buf) = SharedWriter::new();
429 {
430 let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
431 r.render(RuntimeEvent::ToolCallStarted {
432 session_id: session_id(), tool_name: "t1".into(), args_json: "{}".into(),
433 }).unwrap();
434 r.finish_turn().unwrap();
435 r.render(RuntimeEvent::TextDelta {
436 session_id: session_id(), text: "hello".into(),
437 }).unwrap();
438 r.finish_turn().unwrap();
439 }
440 let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
441 assert!(out.contains("1 tool call"));
442 assert!(out.contains("0 tool call"));
443 }
444
445 #[test]
446 fn test_thought_to_text_transition_adds_newline() {
447 let (writer, buf) = SharedWriter::new();
448 {
449 let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
450 r.render(RuntimeEvent::ThoughtDelta {
451 session_id: session_id(),
452 text: "hmm".into(),
453 }).unwrap();
454 r.render(RuntimeEvent::TextDelta {
455 session_id: session_id(),
456 text: "hello".into(),
457 }).unwrap();
458 }
459 let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
460 assert!(out.contains("hmm"));
461 assert!(out.contains("hello"));
462 }
463}