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<()> {
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 #[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 #[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 #[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}