1#![allow(missing_docs)]
2
3use std::io::{self, Write};
4
5use agent_base::{AgentResult, PlanStepStatus, RuntimeEvent};
6
7use crate::render::EventRenderer;
8
9pub struct TerminalRenderer {
14 show_thinking: bool,
15 show_tool_args: bool,
16 color: bool,
17 writer: Box<dyn Write + Send>,
18 tool_call_count: u32,
19 turn_start: Option<std::time::Instant>,
20 last_assistant_text: String,
21 last_was_thought: bool,
22}
23
24impl TerminalRenderer {
25 pub fn new(show_thinking: bool, show_tool_args: bool, color: bool, writer: Box<dyn Write + Send>) -> Self {
32 Self {
33 show_thinking,
34 show_tool_args,
35 color,
36 writer,
37 tool_call_count: 0,
38 turn_start: None,
39 last_assistant_text: String::new(),
40 last_was_thought: false,
41 }
42 }
43
44 pub fn stdout(show_thinking: bool, show_tool_args: bool, color: bool) -> Self {
45 Self::new(show_thinking, show_tool_args, color, Box::new(io::stdout()))
46 }
47
48 fn green(&self, s: &str) -> String {
49 if self.color { format!("\x1b[32m{}\x1b[0m", s) } else { s.to_string() }
50 }
51
52 fn dim(&self, s: &str) -> String {
53 if self.color { format!("\x1b[2m{}\x1b[0m", s) } else { s.to_string() }
54 }
55
56 fn bold(&self, s: &str) -> String {
57 if self.color { format!("\x1b[1m{}\x1b[0m", s) } else { s.to_string() }
58 }
59
60 fn yellow(&self, s: &str) -> String {
61 if self.color { format!("\x1b[33m{}\x1b[0m", s) } else { s.to_string() }
62 }
63
64 fn subtle(&self, s: &str) -> String {
65 if self.color { format!("\x1b[90m{}\x1b[0m", s) } else { s.to_string() }
66 }
67
68 fn write_line(&mut self, s: &str) -> AgentResult<()> {
69 writeln!(self.writer, "{}", s).map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
70 self.writer.flush().map_err(|e| agent_base::AgentError::internal(format!("flush error: {e}")))?;
71 Ok(())
72 }
73
74 fn write_text(&mut self, s: &str) -> AgentResult<()> {
76 write!(self.writer, "{}", s).map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
77 self.writer.flush().map_err(|e| agent_base::AgentError::internal(format!("flush error: {e}")))?;
78 Ok(())
79 }
80}
81
82impl EventRenderer for TerminalRenderer {
83 fn render(&mut self, event: RuntimeEvent) -> AgentResult<()> {
84 if self.turn_start.is_none() {
85 self.turn_start = Some(std::time::Instant::now());
86 }
87
88 let agent_prefix = event.agent_id().map(|id| self.subtle(&format!("[{}]", id)));
91 let is_subagent = agent_prefix.is_some();
92
93 match &event {
94 RuntimeEvent::ThoughtDelta { text, .. } => {
95 if self.show_thinking {
96 match &agent_prefix {
97 Some(prefix) => self.write_text(&format!("{} {}", prefix, self.dim(text)))?,
98 None => self.write_text(&self.dim(text))?,
99 }
100 }
101 self.last_was_thought = true;
102 },
103 RuntimeEvent::TextDelta { text, .. } => {
104 if self.last_was_thought {
105 let _ = writeln!(self.writer);
106 self.last_was_thought = false;
107 }
108 if !is_subagent {
109 self.last_assistant_text.push_str(text);
110 }
111 match &agent_prefix {
112 Some(prefix) => self.write_text(&format!("{} {}", prefix, text))?,
113 None => self.write_text(text)?,
114 }
115 },
116 RuntimeEvent::ToolCallStarted { tool_name, args_json, .. } => {
117 self.last_was_thought = false;
118 self.tool_call_count += 1;
119 let head = match &agent_prefix {
120 Some(prefix) => format!("{} {}", prefix, self.bold("\u{1F527}")),
121 None => self.bold("\u{1F527}"),
122 };
123 if self.show_tool_args {
124 self.write_line(&format!("\n{} {} {}", head, self.green(tool_name), self.dim(args_json)))?;
125 } else {
126 self.write_line(&format!("\n{} {}", head, self.green(tool_name)))?;
127 }
128 },
129 RuntimeEvent::ToolCallFinished { tool_name: _, summary, .. } => {
130 let summary_short: String = if summary.chars().count() > 500 {
131 let truncated: String = summary.chars().take(500).collect();
132 format!("{}...", truncated)
133 } else {
134 summary.clone()
135 };
136 match &agent_prefix {
137 Some(prefix) => {
138 self.write_line(&format!("{} {} {}", prefix, self.dim("→"), self.dim(&summary_short)))?
139 },
140 None => self.write_line(&format!(" {} {}", self.dim("→"), self.dim(&summary_short)))?,
141 }
142 let _ = writeln!(self.writer);
144 },
145 RuntimeEvent::AwaitingApproval { request, .. } => {
146 self.write_line(&format!("\n⚠️ {} [{:?}] — {}", request.title, request.risk_level, request.message,))?;
147 },
148 RuntimeEvent::PlanUpdated { explanation, plan, .. } => {
149 self.write_line(&format!("\n\u{1F4CB} {}", self.bold("Plan Update")))?;
150 self.write_line(&format!(" {}", self.dim(explanation.as_deref().unwrap_or(""))))?;
151 for item in plan {
152 let icon = match item.status {
153 PlanStepStatus::Completed => "✅",
154 PlanStepStatus::InProgress => "\u{1F504}",
155 PlanStepStatus::Pending => "⏳",
156 };
157 self.write_line(&format!(" {} {}", icon, item.step))?;
158 }
159 let _ = writeln!(self.writer);
160 },
161 RuntimeEvent::RunCancelled { .. } => match &agent_prefix {
162 Some(prefix) => self.write_line(&format!("{} {} Cancelled", prefix, self.yellow("⚠")))?,
163 None => self.write_line(&format!("\n{} Cancelled", self.yellow("⚠")))?,
164 },
165 RuntimeEvent::RunFinished { .. } => {},
166 RuntimeEvent::UserEvent { .. } => {},
167 RuntimeEvent::Checkpoint { .. } => {},
168 }
169
170 Ok(())
171 }
172
173 fn finish_turn(&mut self) -> AgentResult<()> {
174 let duration_ms = self.turn_start.map(|s| s.elapsed().as_millis() as u64).unwrap_or(0);
175
176 let duration_str = if duration_ms >= 1000 {
177 format!("{:.1}s", duration_ms as f64 / 1000.0)
178 } else {
179 format!("{}ms", duration_ms)
180 };
181
182 writeln!(
183 self.writer,
184 "\n{}",
185 self.subtle(&format!("· {} elapsed · {} tool call(s)", duration_str, self.tool_call_count)),
186 )
187 .map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
188
189 self.tool_call_count = 0;
190 self.turn_start = None;
191 self.last_assistant_text.clear();
192 self.last_was_thought = false;
193
194 Ok(())
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use agent_base::{ApprovalRequest, PlanItem, PlanStepStatus, RiskLevel, SessionId, UserEvent};
202 use std::io::Write;
203 use std::sync::{Arc, Mutex};
204
205 struct SharedWriter {
207 inner: Arc<Mutex<Vec<u8>>>,
208 }
209
210 impl Write for SharedWriter {
211 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
212 self.inner.lock().unwrap().extend_from_slice(data);
213 Ok(data.len())
214 }
215 fn flush(&mut self) -> std::io::Result<()> {
216 Ok(())
217 }
218 }
219
220 impl SharedWriter {
221 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
222 let inner = Arc::new(Mutex::new(Vec::new()));
223 (Self { inner: inner.clone() }, inner)
224 }
225 }
226
227 fn session_id() -> SessionId {
228 SessionId { id: 1, external_id: None }
229 }
230
231 fn render_one(show_thinking: bool, show_tool_args: bool, color: bool, event: RuntimeEvent) -> String {
232 let (writer, buf) = SharedWriter::new();
233 let mut r = TerminalRenderer::new(show_thinking, show_tool_args, color, Box::new(writer));
234 r.render(event).unwrap();
235 drop(r);
236 String::from_utf8(buf.lock().unwrap().clone()).unwrap()
237 }
238
239 fn render_events(show_thinking: bool, show_tool_args: bool, color: bool, events: &[RuntimeEvent]) -> String {
240 let (writer, buf) = SharedWriter::new();
241 let mut r = TerminalRenderer::new(show_thinking, show_tool_args, color, Box::new(writer));
242 for e in events {
243 r.render(e.clone()).unwrap();
244 }
245 r.finish_turn().unwrap();
246 drop(r);
247 String::from_utf8(buf.lock().unwrap().clone()).unwrap()
248 }
249
250 #[test]
253 fn test_color_methods_enabled() {
254 let (writer, _buf) = SharedWriter::new();
255 let r = TerminalRenderer::new(true, true, true, Box::new(writer));
256 assert!(r.green("hello").contains("\x1b[32m"));
257 assert!(r.dim("hello").contains("\x1b[2m"));
258 assert!(r.bold("hello").contains("\x1b[1m"));
259 assert!(r.yellow("hello").contains("\x1b[33m"));
260 assert!(r.subtle("hello").contains("\x1b[90m"));
261 assert!(r.green("hello").ends_with("\x1b[0m"));
262 }
263
264 #[test]
265 fn test_color_methods_disabled() {
266 let (writer, _buf) = SharedWriter::new();
267 let r = TerminalRenderer::new(true, true, false, Box::new(writer));
268 assert!(!r.green("hello").contains('\x1b'));
269 assert_eq!(r.green("hello"), "hello");
270 assert_eq!(r.dim("x"), "x");
271 assert_eq!(r.bold("x"), "x");
272 assert_eq!(r.yellow("x"), "x");
273 assert_eq!(r.subtle("x"), "x");
274 }
275
276 #[test]
279 fn test_render_text_delta() {
280 let out = render_one(
281 true,
282 true,
283 true,
284 RuntimeEvent::TextDelta {
285 session_id: session_id(),
286 text: "hello world".into(),
287 agent_id: None,
288 trace_id: None,
289 },
290 );
291 assert!(out.contains("hello world"));
292 }
293
294 #[test]
295 fn test_render_thought_delta_shown() {
296 let out = render_one(
297 true,
298 true,
299 true,
300 RuntimeEvent::ThoughtDelta {
301 session_id: session_id(),
302 text: "thinking...".into(),
303 agent_id: None,
304 trace_id: None,
305 },
306 );
307 assert!(out.contains("thinking..."));
308 }
309
310 #[test]
311 fn test_render_thought_delta_hidden() {
312 let out = render_one(
313 false,
314 true,
315 true,
316 RuntimeEvent::ThoughtDelta {
317 session_id: session_id(),
318 text: "secret thought".into(),
319 agent_id: None,
320 trace_id: None,
321 },
322 );
323 assert!(!out.contains("secret thought"));
324 }
325
326 #[test]
327 fn test_render_tool_call_started_with_args() {
328 let out = render_one(
329 true,
330 true,
331 true,
332 RuntimeEvent::ToolCallStarted {
333 session_id: session_id(),
334 tool_name: "read_file".into(),
335 args_json: r#"{"path":"/tmp/a.txt"}"#.into(),
336 agent_id: None,
337 trace_id: None,
338 },
339 );
340 assert!(out.contains("read_file"));
341 assert!(out.contains("a.txt"));
342 }
343
344 #[test]
345 fn test_render_tool_call_started_without_args() {
346 let out = render_one(
347 true,
348 false,
349 true,
350 RuntimeEvent::ToolCallStarted {
351 session_id: session_id(),
352 tool_name: "read_file".into(),
353 args_json: r#"{"path":"/tmp/a.txt"}"#.into(),
354 agent_id: None,
355 trace_id: None,
356 },
357 );
358 assert!(out.contains("read_file"));
359 assert!(!out.contains("a.txt"));
360 }
361
362 #[test]
363 fn test_render_tool_call_finished_short_summary() {
364 let out = render_one(
365 true,
366 true,
367 true,
368 RuntimeEvent::ToolCallFinished {
369 session_id: session_id(),
370 tool_name: "read_file".into(),
371 summary: "file contents here".into(),
372 agent_id: None,
373 trace_id: None,
374 denied: false,
375 },
376 );
377 assert!(out.contains("file contents here"));
378 }
379
380 #[test]
381 fn test_render_tool_call_finished_truncated() {
382 let long = "x".repeat(600);
383 let out = render_one(
384 true,
385 true,
386 true,
387 RuntimeEvent::ToolCallFinished {
388 session_id: session_id(),
389 tool_name: "read_file".into(),
390 summary: long.clone(),
391 agent_id: None,
392 trace_id: None,
393 denied: false,
394 },
395 );
396 assert!(!out.contains(&long));
397 assert!(out.contains("..."));
398 assert!(out.contains(&"x".repeat(400)));
399 }
400
401 #[test]
402 fn test_render_awaiting_approval() {
403 let out = render_one(
404 true,
405 true,
406 true,
407 RuntimeEvent::AwaitingApproval {
408 session_id: session_id(),
409 request: ApprovalRequest {
410 title: "Delete file".into(),
411 message: "This will delete /tmp/important.txt".into(),
412 action_key: None,
413 risk_level: RiskLevel::Destructive,
414 raw: None,
415 },
416 agent_id: None,
417 trace_id: None,
418 },
419 );
420 assert!(out.contains("Delete file"));
421 assert!(out.contains("Destructive"));
422 }
423
424 #[test]
425 fn test_render_plan_updated() {
426 let out = render_one(
427 true,
428 true,
429 true,
430 RuntimeEvent::PlanUpdated {
431 session_id: session_id(),
432 objective: "test plan".into(),
433 explanation: Some("starting work".into()),
434 plan: vec![
435 PlanItem { step: "Step 1".into(), status: PlanStepStatus::Completed },
436 PlanItem { step: "Step 2".into(), status: PlanStepStatus::InProgress },
437 PlanItem { step: "Step 3".into(), status: PlanStepStatus::Pending },
438 ],
439 agent_id: None,
440 trace_id: None,
441 },
442 );
443 assert!(out.contains("Plan Update"));
444 assert!(out.contains("starting work"));
445 assert!(out.contains("✅"));
446 assert!(out.contains("Step 1"));
447 assert!(out.contains("Step 2"));
448 assert!(out.contains("Step 3"));
449 }
450
451 #[test]
452 fn test_render_run_cancelled() {
453 let out = render_one(
454 true,
455 true,
456 true,
457 RuntimeEvent::RunCancelled { session_id: session_id(), agent_id: None, trace_id: None },
458 );
459 assert!(out.contains("Cancelled"));
460 }
461
462 #[test]
463 fn test_render_run_finished_no_output() {
464 let out = render_one(
465 true,
466 true,
467 true,
468 RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None },
469 );
470 assert!(out.is_empty());
471 }
472
473 #[test]
474 fn test_render_user_event_progress_no_output() {
475 let out = render_one(
476 true,
477 true,
478 true,
479 RuntimeEvent::UserEvent {
480 session_id: session_id(),
481 event: UserEvent::Progress { text: "loading...".into() },
482 agent_id: None,
483 trace_id: None,
484 },
485 );
486 assert!(out.is_empty());
487 }
488
489 #[test]
492 fn test_finish_turn_contains_duration_and_tool_count() {
493 let out = render_events(
494 true,
495 true,
496 true,
497 &[RuntimeEvent::TextDelta { session_id: session_id(), text: "hi".into(), agent_id: None, trace_id: None }],
498 );
499 assert!(out.contains("elapsed"));
500 assert!(out.contains("tool call"));
501 }
502
503 #[test]
504 fn test_finish_turn_tool_count() {
505 let out = render_events(
506 true,
507 true,
508 true,
509 &[
510 RuntimeEvent::ToolCallStarted {
511 session_id: session_id(),
512 tool_name: "a".into(),
513 args_json: "{}".into(),
514 agent_id: None,
515 trace_id: None,
516 },
517 RuntimeEvent::ToolCallStarted {
518 session_id: session_id(),
519 tool_name: "b".into(),
520 args_json: "{}".into(),
521 agent_id: None,
522 trace_id: None,
523 },
524 RuntimeEvent::ToolCallStarted {
525 session_id: session_id(),
526 tool_name: "c".into(),
527 args_json: "{}".into(),
528 agent_id: None,
529 trace_id: None,
530 },
531 ],
532 );
533 assert!(out.contains("3 tool call"));
534 }
535
536 #[test]
537 fn test_multiple_turns_reset() {
538 let (writer, buf) = SharedWriter::new();
539 {
540 let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
541 r.render(RuntimeEvent::ToolCallStarted {
542 session_id: session_id(),
543 tool_name: "t1".into(),
544 args_json: "{}".into(),
545 agent_id: None,
546 trace_id: None,
547 })
548 .unwrap();
549 r.finish_turn().unwrap();
550 r.render(RuntimeEvent::TextDelta {
551 session_id: session_id(),
552 text: "hello".into(),
553 agent_id: None,
554 trace_id: None,
555 })
556 .unwrap();
557 r.finish_turn().unwrap();
558 }
559 let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
560 assert!(out.contains("1 tool call"));
561 assert!(out.contains("0 tool call"));
562 }
563
564 #[test]
565 fn test_thought_to_text_transition_adds_newline() {
566 let (writer, buf) = SharedWriter::new();
567 {
568 let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
569 r.render(RuntimeEvent::ThoughtDelta {
570 session_id: session_id(),
571 text: "hmm".into(),
572 agent_id: None,
573 trace_id: None,
574 })
575 .unwrap();
576 r.render(RuntimeEvent::TextDelta {
577 session_id: session_id(),
578 text: "hello".into(),
579 agent_id: None,
580 trace_id: None,
581 })
582 .unwrap();
583 }
584 let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
585 assert!(out.contains("hmm"));
586 assert!(out.contains("hello"));
587 }
588
589 #[test]
592 fn test_render_subagent_text_delta() {
593 let out = render_one(
594 true,
595 true,
596 false,
597 RuntimeEvent::TextDelta {
598 session_id: session_id(),
599 text: "found results".into(),
600 agent_id: Some("root/searcher".into()),
601 trace_id: None,
602 },
603 );
604 assert!(out.contains("[root/searcher]"));
605 assert!(out.contains("found results"));
606 }
607
608 #[test]
609 fn test_render_subagent_tool_call() {
610 let out = render_one(
611 true,
612 true,
613 false,
614 RuntimeEvent::ToolCallStarted {
615 session_id: session_id(),
616 tool_name: "search".into(),
617 args_json: r#"{"q":"test"}"#.into(),
618 agent_id: Some("root/worker".into()),
619 trace_id: None,
620 },
621 );
622 assert!(out.contains("[root/worker]"));
623 assert!(out.contains("search"));
624 assert!(out.contains("test"));
625 }
626
627 #[test]
628 fn test_render_subagent_thought_hidden() {
629 let out = render_one(
630 false,
631 true,
632 false,
633 RuntimeEvent::ThoughtDelta {
634 session_id: session_id(),
635 text: "secret plan".into(),
636 agent_id: Some("root/thinker".into()),
637 trace_id: None,
638 },
639 );
640 assert!(!out.contains("secret plan"));
641 }
642
643 #[test]
644 fn test_render_subagent_run_cancelled() {
645 let out = render_one(
646 true,
647 true,
648 false,
649 RuntimeEvent::RunCancelled {
650 session_id: session_id(),
651 agent_id: Some("root/worker".into()),
652 trace_id: None,
653 },
654 );
655 assert!(out.contains("[root/worker]"));
656 assert!(out.contains("Cancelled"));
657 }
658}