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