1#![allow(missing_docs)]
2
3use std::io::{self, Write};
4
5use agent_base::{AgentResult, PlanStepStatus, RuntimeEvent};
6
7use agent_works::compression::{CompressionEvent, CompressionTrigger};
8
9use crate::render::EventRenderer;
10
11pub struct TerminalRenderer {
16 show_thinking: bool,
17 show_tool_args: bool,
18 color: bool,
19 writer: Box<dyn Write + Send>,
20 tool_call_count: u32,
21 turn_start: Option<std::time::Instant>,
22 last_assistant_text: String,
23 last_was_thought: bool,
24 compression_started_at: Option<std::time::Instant>,
25 last_progress_chars: usize,
26 first_output_logged: bool,
27}
28
29impl TerminalRenderer {
30 pub fn new(show_thinking: bool, show_tool_args: bool, color: bool, writer: Box<dyn Write + Send>) -> Self {
37 Self {
38 show_thinking,
39 show_tool_args,
40 color,
41 writer,
42 tool_call_count: 0,
43 turn_start: None,
44 last_assistant_text: String::new(),
45 last_was_thought: false,
46 compression_started_at: None,
47 last_progress_chars: 0,
48 first_output_logged: false,
49 }
50 }
51
52 pub fn stdout(show_thinking: bool, show_tool_args: bool, color: bool) -> Self {
53 Self::new(show_thinking, show_tool_args, color, Box::new(io::stdout()))
54 }
55
56 fn green(&self, s: &str) -> String {
57 if self.color { format!("\x1b[32m{}\x1b[0m", s) } else { s.to_string() }
58 }
59
60 fn dim(&self, s: &str) -> String {
61 if self.color { format!("\x1b[2m{}\x1b[0m", s) } else { s.to_string() }
62 }
63
64 fn bold(&self, s: &str) -> String {
65 if self.color { format!("\x1b[1m{}\x1b[0m", s) } else { s.to_string() }
66 }
67
68 fn yellow(&self, s: &str) -> String {
69 if self.color { format!("\x1b[33m{}\x1b[0m", s) } else { s.to_string() }
70 }
71
72 fn subtle(&self, s: &str) -> String {
73 if self.color { format!("\x1b[90m{}\x1b[0m", s) } else { s.to_string() }
74 }
75
76 fn write_line(&mut self, s: &str) -> AgentResult<()> {
77 writeln!(self.writer, "{}", s).map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
78 self.writer.flush().map_err(|e| agent_base::AgentError::internal(format!("flush error: {e}")))?;
79 Ok(())
80 }
81
82 fn write_text(&mut self, s: &str) -> AgentResult<()> {
84 write!(self.writer, "{}", s).map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
85 self.writer.flush().map_err(|e| agent_base::AgentError::internal(format!("flush error: {e}")))?;
86 Ok(())
87 }
88}
89
90impl EventRenderer for TerminalRenderer {
91 fn render(&mut self, event: RuntimeEvent) -> AgentResult<()> {
92 if self.turn_start.is_none() {
93 self.turn_start = Some(std::time::Instant::now());
94 }
95
96 let agent_prefix = event.agent_id().map(|id| self.subtle(&format!("[{}]", id)));
99 let is_subagent = agent_prefix.is_some();
100
101 match &event {
102 RuntimeEvent::ThoughtDelta { text, .. } => {
103 if !self.first_output_logged
104 && let Some(start) = self.turn_start
105 {
106 let ttft_ms = start.elapsed().as_millis();
107 tracing::info!(ttft_ms = ttft_ms as u64, "first visible output (thought)");
108 self.first_output_logged = true;
109 }
110 if self.show_thinking {
111 match &agent_prefix {
112 Some(prefix) => self.write_text(&format!("{} {}", prefix, self.dim(text)))?,
113 None => self.write_text(&self.dim(text))?,
114 }
115 }
116 self.last_was_thought = true;
117 },
118 RuntimeEvent::TextDelta { text, .. } => {
119 if !self.first_output_logged
120 && let Some(start) = self.turn_start
121 {
122 let ttft_ms = start.elapsed().as_millis();
123 tracing::info!(ttft_ms = ttft_ms as u64, "first visible output");
124 self.first_output_logged = true;
125 }
126 if self.last_was_thought {
127 let _ = writeln!(self.writer);
128 self.last_was_thought = false;
129 }
130 if !is_subagent {
131 self.last_assistant_text.push_str(text);
132 }
133 match &agent_prefix {
134 Some(prefix) => self.write_text(&format!("{} {}", prefix, text))?,
135 None => self.write_text(text)?,
136 }
137 },
138 RuntimeEvent::ToolCallStarted { tool_name, args_json, .. } => {
139 self.last_was_thought = false;
140 self.tool_call_count += 1;
141 let head = match &agent_prefix {
142 Some(prefix) => format!("{} {}", prefix, self.bold("\u{1F527}")),
143 None => self.bold("\u{1F527}"),
144 };
145 if self.show_tool_args {
146 self.write_line(&format!("\n{} {} {}", head, self.green(tool_name), self.dim(args_json)))?;
147 } else {
148 self.write_line(&format!("\n{} {}", head, self.green(tool_name)))?;
149 }
150 },
151 RuntimeEvent::ToolCallFinished { tool_name: _, summary, .. } => {
152 let summary_short: String = if summary.chars().count() > 500 {
153 let truncated: String = summary.chars().take(500).collect();
154 format!("{}...", truncated)
155 } else {
156 summary.clone()
157 };
158 match &agent_prefix {
159 Some(prefix) => {
160 self.write_line(&format!("{} {} {}", prefix, self.dim("→"), self.dim(&summary_short)))?
161 },
162 None => self.write_line(&format!(" {} {}", self.dim("→"), self.dim(&summary_short)))?,
163 }
164 let _ = writeln!(self.writer);
166 },
167 RuntimeEvent::AwaitingApproval { request, .. } => {
168 self.write_line(&format!("\n⚠️ {} [{:?}] — {}", request.title, request.risk_level, request.message,))?;
169 },
170 RuntimeEvent::PlanUpdated { explanation, plan, .. } => {
171 self.write_line(&format!("\n\u{1F4CB} {}", self.bold("Plan Update")))?;
172 self.write_line(&format!(" {}", self.dim(explanation.as_deref().unwrap_or(""))))?;
173 for item in plan {
174 let icon = match item.status {
175 PlanStepStatus::Completed => "✅",
176 PlanStepStatus::InProgress => "\u{1F504}",
177 PlanStepStatus::Pending => "⏳",
178 };
179 self.write_line(&format!(" {} {}", icon, item.step))?;
180 }
181 let _ = writeln!(self.writer);
182 },
183 RuntimeEvent::RunCancelled { .. } => match &agent_prefix {
184 Some(prefix) => self.write_line(&format!("{} {} Cancelled", prefix, self.yellow("⚠")))?,
185 None => self.write_line(&format!("\n{} Cancelled", self.yellow("⚠")))?,
186 },
187 RuntimeEvent::RunFinished { .. } => {},
188 RuntimeEvent::UserEvent { event: agent_base::UserEvent::Progress { text }, .. } => {
189 self.write_line(&format!("\r\x1b[K{}", self.green(text)))?;
190 },
191 RuntimeEvent::UserEvent { event, .. }
192 if matches!(
193 CompressionEvent::from_user_event(event),
194 Some(CompressionEvent::Preparing { .. })
195 | Some(CompressionEvent::Started { .. })
196 | Some(CompressionEvent::Progress { .. })
197 | Some(CompressionEvent::Completed { .. })
198 | Some(CompressionEvent::Failed { .. })
199 ) =>
200 {
201 if let Some(ev) = CompressionEvent::from_user_event(event) {
203 match ev {
204 CompressionEvent::Preparing { tokens_before, msg_count, trigger, .. } => {
205 self.compression_started_at = Some(std::time::Instant::now());
206 if matches!(trigger, CompressionTrigger::Manual) {
209 self.write_line(&format!(
210 "\r\x1b[K{}",
211 self.yellow(&format!(
212 "⏳ Manual compression (~{} tokens, {} messages)...",
213 tokens_before, msg_count
214 ))
215 ))?;
216 }
217 },
218 CompressionEvent::Started { tokens_before, msg_count, .. } => {
219 self.compression_started_at = Some(std::time::Instant::now());
220 self.last_progress_chars = 0;
221 self.write_line(&format!(
222 "\r\x1b[K{}",
223 self.yellow(&format!(
224 "⏳ Compressing context (~{} tokens, {} messages)... (0.0s)",
225 tokens_before, msg_count
226 ))
227 ))?;
228 },
229 CompressionEvent::Progress { chars, .. } => {
230 let elapsed = self
231 .compression_started_at
232 .map(|t| format!("{:.1}s", t.elapsed().as_secs_f64()))
233 .unwrap_or_default();
234 let suffix = if elapsed.is_empty() { String::new() } else { format!(" ({elapsed})") };
235 if chars == 0 {
236 self.write_line(&format!(
237 "\r\x1b[K{}",
238 self.yellow(&format!("⏳ Connecting to LLM...{suffix}"))
239 ))?;
240 } else {
241 if chars.saturating_sub(self.last_progress_chars) >= 200 {
243 self.last_progress_chars = chars;
244 self.write_line(&format!(
245 "\r\x1b[K{}",
246 self.yellow(&format!("⏳ Summarizing... ({} chars){suffix}", chars))
247 ))?;
248 }
249 }
250 },
251 CompressionEvent::Completed {
252 tokens_before,
253 tokens_after,
254 reduction_pct,
255 msg_count_before,
256 msg_count_after,
257 ..
258 } => {
259 let elapsed = self
260 .compression_started_at
261 .map(|t| format!("{:.1}s", t.elapsed().as_secs_f64()))
262 .unwrap_or_default();
263 let suffix = if elapsed.is_empty() { String::new() } else { format!(" in {elapsed}") };
264 let icon = if reduction_pct < 0 { "⚠️" } else { "✅" };
265 self.write_line(&format!(
266 "\r\x1b[K{}",
267 self.green(&format!(
268 "{} Context compressed ({} → {} tokens, {}%, {} → {} messages){suffix}",
269 icon, tokens_before, tokens_after, reduction_pct, msg_count_before, msg_count_after
270 ))
271 ))?;
272 self.compression_started_at = None;
273 },
274 CompressionEvent::Failed { error, .. } => {
275 self.write_line(&format!(
276 "\r\x1b[K{}",
277 self.yellow(&format!("❌ Compression failed: {}", error))
278 ))?;
279 self.compression_started_at = None;
280 },
281 }
282 }
283 },
284 RuntimeEvent::UserEvent { .. } => {},
285 RuntimeEvent::Checkpoint { .. } => {},
286 }
287
288 Ok(())
289 }
290
291 fn finish_turn(&mut self) -> AgentResult<()> {
292 let duration_ms = self.turn_start.map(|s| s.elapsed().as_millis() as u64).unwrap_or(0);
293
294 let duration_str = if duration_ms >= 1000 {
295 format!("{:.1}s", duration_ms as f64 / 1000.0)
296 } else {
297 format!("{}ms", duration_ms)
298 };
299
300 writeln!(
301 self.writer,
302 "\n{}",
303 self.subtle(&format!("· {} elapsed · {} tool call(s)", duration_str, self.tool_call_count)),
304 )
305 .map_err(|e| agent_base::AgentError::internal(format!("write error: {e}")))?;
306
307 self.tool_call_count = 0;
308 self.turn_start = None;
309 self.last_assistant_text.clear();
310 self.last_was_thought = false;
311 self.first_output_logged = false;
312
313 Ok(())
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use agent_base::{ApprovalRequest, PlanItem, PlanStepStatus, RiskLevel, SessionId, UserEvent};
321 use agent_works::compression::{CompressionEvent, CompressionTrigger};
322 use std::io::Write;
323 use std::sync::{Arc, Mutex};
324
325 struct SharedWriter {
327 inner: Arc<Mutex<Vec<u8>>>,
328 }
329
330 impl Write for SharedWriter {
331 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
332 self.inner.lock().unwrap().extend_from_slice(data);
333 Ok(data.len())
334 }
335 fn flush(&mut self) -> std::io::Result<()> {
336 Ok(())
337 }
338 }
339
340 impl SharedWriter {
341 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
342 let inner = Arc::new(Mutex::new(Vec::new()));
343 (Self { inner: inner.clone() }, inner)
344 }
345 }
346
347 fn session_id() -> SessionId {
348 SessionId { id: 1, external_id: None }
349 }
350
351 fn render_one(show_thinking: bool, show_tool_args: bool, color: bool, event: RuntimeEvent) -> String {
352 let (writer, buf) = SharedWriter::new();
353 let mut r = TerminalRenderer::new(show_thinking, show_tool_args, color, Box::new(writer));
354 r.render(event).unwrap();
355 drop(r);
356 String::from_utf8(buf.lock().unwrap().clone()).unwrap()
357 }
358
359 fn render_events(show_thinking: bool, show_tool_args: bool, color: bool, events: &[RuntimeEvent]) -> String {
360 let (writer, buf) = SharedWriter::new();
361 let mut r = TerminalRenderer::new(show_thinking, show_tool_args, color, Box::new(writer));
362 for e in events {
363 r.render(e.clone()).unwrap();
364 }
365 r.finish_turn().unwrap();
366 drop(r);
367 String::from_utf8(buf.lock().unwrap().clone()).unwrap()
368 }
369
370 #[test]
373 fn test_color_methods_enabled() {
374 let (writer, _buf) = SharedWriter::new();
375 let r = TerminalRenderer::new(true, true, true, Box::new(writer));
376 assert!(r.green("hello").contains("\x1b[32m"));
377 assert!(r.dim("hello").contains("\x1b[2m"));
378 assert!(r.bold("hello").contains("\x1b[1m"));
379 assert!(r.yellow("hello").contains("\x1b[33m"));
380 assert!(r.subtle("hello").contains("\x1b[90m"));
381 assert!(r.green("hello").ends_with("\x1b[0m"));
382 }
383
384 #[test]
385 fn test_color_methods_disabled() {
386 let (writer, _buf) = SharedWriter::new();
387 let r = TerminalRenderer::new(true, true, false, Box::new(writer));
388 assert!(!r.green("hello").contains('\x1b'));
389 assert_eq!(r.green("hello"), "hello");
390 assert_eq!(r.dim("x"), "x");
391 assert_eq!(r.bold("x"), "x");
392 assert_eq!(r.yellow("x"), "x");
393 assert_eq!(r.subtle("x"), "x");
394 }
395
396 #[test]
399 fn test_render_text_delta() {
400 let out = render_one(
401 true,
402 true,
403 true,
404 RuntimeEvent::TextDelta {
405 session_id: session_id(),
406 text: "hello world".into(),
407 agent_id: None,
408 trace_id: None,
409 },
410 );
411 assert!(out.contains("hello world"));
412 }
413
414 #[test]
415 fn test_render_thought_delta_shown() {
416 let out = render_one(
417 true,
418 true,
419 true,
420 RuntimeEvent::ThoughtDelta {
421 session_id: session_id(),
422 text: "thinking...".into(),
423 agent_id: None,
424 trace_id: None,
425 },
426 );
427 assert!(out.contains("thinking..."));
428 }
429
430 #[test]
431 fn test_render_thought_delta_hidden() {
432 let out = render_one(
433 false,
434 true,
435 true,
436 RuntimeEvent::ThoughtDelta {
437 session_id: session_id(),
438 text: "secret thought".into(),
439 agent_id: None,
440 trace_id: None,
441 },
442 );
443 assert!(!out.contains("secret thought"));
444 }
445
446 #[test]
447 fn test_render_tool_call_started_with_args() {
448 let out = render_one(
449 true,
450 true,
451 true,
452 RuntimeEvent::ToolCallStarted {
453 session_id: session_id(),
454 tool_name: "read_file".into(),
455 args_json: r#"{"path":"/tmp/a.txt"}"#.into(),
456 agent_id: None,
457 trace_id: None,
458 },
459 );
460 assert!(out.contains("read_file"));
461 assert!(out.contains("a.txt"));
462 }
463
464 #[test]
465 fn test_render_tool_call_started_without_args() {
466 let out = render_one(
467 true,
468 false,
469 true,
470 RuntimeEvent::ToolCallStarted {
471 session_id: session_id(),
472 tool_name: "read_file".into(),
473 args_json: r#"{"path":"/tmp/a.txt"}"#.into(),
474 agent_id: None,
475 trace_id: None,
476 },
477 );
478 assert!(out.contains("read_file"));
479 assert!(!out.contains("a.txt"));
480 }
481
482 #[test]
483 fn test_render_tool_call_finished_short_summary() {
484 let out = render_one(
485 true,
486 true,
487 true,
488 RuntimeEvent::ToolCallFinished {
489 session_id: session_id(),
490 tool_name: "read_file".into(),
491 summary: "file contents here".into(),
492 agent_id: None,
493 trace_id: None,
494 denied: false,
495 details: None,
496 },
497 );
498 assert!(out.contains("file contents here"));
499 }
500
501 #[test]
502 fn test_render_tool_call_finished_truncated() {
503 let long = "x".repeat(600);
504 let out = render_one(
505 true,
506 true,
507 true,
508 RuntimeEvent::ToolCallFinished {
509 session_id: session_id(),
510 tool_name: "read_file".into(),
511 summary: long.clone(),
512 agent_id: None,
513 trace_id: None,
514 denied: false,
515 details: None,
516 },
517 );
518 assert!(!out.contains(&long));
519 assert!(out.contains("..."));
520 assert!(out.contains(&"x".repeat(400)));
521 }
522
523 #[test]
524 fn test_render_awaiting_approval() {
525 let out = render_one(
526 true,
527 true,
528 true,
529 RuntimeEvent::AwaitingApproval {
530 session_id: session_id(),
531 request: ApprovalRequest {
532 title: "Delete file".into(),
533 message: "This will delete /tmp/important.txt".into(),
534 action_key: None,
535 risk_level: RiskLevel::Destructive,
536 raw: None,
537 source: None,
538 },
539 agent_id: None,
540 trace_id: None,
541 },
542 );
543 assert!(out.contains("Delete file"));
544 assert!(out.contains("Destructive"));
545 }
546
547 #[test]
548 fn test_render_plan_updated() {
549 let out = render_one(
550 true,
551 true,
552 true,
553 RuntimeEvent::PlanUpdated {
554 session_id: session_id(),
555 objective: "test plan".into(),
556 explanation: Some("starting work".into()),
557 plan: vec![
558 PlanItem { step: "Step 1".into(), status: PlanStepStatus::Completed },
559 PlanItem { step: "Step 2".into(), status: PlanStepStatus::InProgress },
560 PlanItem { step: "Step 3".into(), status: PlanStepStatus::Pending },
561 ],
562 agent_id: None,
563 trace_id: None,
564 },
565 );
566 assert!(out.contains("Plan Update"));
567 assert!(out.contains("starting work"));
568 assert!(out.contains("✅"));
569 assert!(out.contains("Step 1"));
570 assert!(out.contains("Step 2"));
571 assert!(out.contains("Step 3"));
572 }
573
574 #[test]
575 fn test_render_run_cancelled() {
576 let out = render_one(
577 true,
578 true,
579 true,
580 RuntimeEvent::RunCancelled { session_id: session_id(), agent_id: None, trace_id: None },
581 );
582 assert!(out.contains("Cancelled"));
583 }
584
585 #[test]
586 fn test_render_run_finished_no_output() {
587 let out = render_one(
588 true,
589 true,
590 true,
591 RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None },
592 );
593 assert!(out.is_empty());
594 }
595
596 #[test]
597 fn test_render_user_event_progress_shows_text() {
598 let out = render_one(
599 true,
600 true,
601 true,
602 RuntimeEvent::UserEvent {
603 session_id: session_id(),
604 event: UserEvent::Progress { text: "loading...".into() },
605 agent_id: None,
606 trace_id: None,
607 },
608 );
609 assert!(out.contains("loading..."));
610 }
611
612 #[test]
613 fn test_render_compression_preparing_auto_suppressed() {
614 let out = render_one(
616 true,
617 true,
618 true,
619 RuntimeEvent::UserEvent {
620 session_id: session_id(),
621 event: CompressionEvent::Preparing {
622 session_id: 1,
623 tokens_before: 4200,
624 msg_count: 8,
625 trigger: CompressionTrigger::Auto,
626 }
627 .into_user_event(),
628 agent_id: None,
629 trace_id: None,
630 },
631 );
632 assert!(out.is_empty(), "Auto Preparing should be suppressed, got: {out}");
633 }
634
635 #[test]
636 fn test_render_compression_preparing_manual() {
637 let out = render_one(
638 true,
639 true,
640 true,
641 RuntimeEvent::UserEvent {
642 session_id: session_id(),
643 event: CompressionEvent::Preparing {
644 session_id: 1,
645 tokens_before: 5000,
646 msg_count: 10,
647 trigger: CompressionTrigger::Manual,
648 }
649 .into_user_event(),
650 agent_id: None,
651 trace_id: None,
652 },
653 );
654 assert!(out.contains("Manual compression"));
655 assert!(out.contains("5000"));
656 }
657
658 #[test]
659 fn test_render_compression_started_event() {
660 let out = render_one(
661 true,
662 true,
663 true,
664 RuntimeEvent::UserEvent {
665 session_id: session_id(),
666 event: CompressionEvent::Started {
667 session_id: 1,
668 tokens_before: 5000,
669 msg_count: 8,
670 trigger: CompressionTrigger::Auto,
671 }
672 .into_user_event(),
673 agent_id: None,
674 trace_id: None,
675 },
676 );
677 assert!(out.contains("Compressing"));
678 assert!(out.contains("5000"));
679 }
680
681 #[test]
682 fn test_render_compression_progress_connecting() {
683 let out = render_one(
684 true,
685 true,
686 true,
687 RuntimeEvent::UserEvent {
688 session_id: session_id(),
689 event: CompressionEvent::Progress { session_id: 1, chars: 0 }.into_user_event(),
690 agent_id: None,
691 trace_id: None,
692 },
693 );
694 assert!(out.contains("Connecting"));
695 }
696
697 #[test]
698 fn test_render_compression_progress_summarizing() {
699 let out = render_one(
700 true,
701 true,
702 true,
703 RuntimeEvent::UserEvent {
704 session_id: session_id(),
705 event: CompressionEvent::Progress { session_id: 1, chars: 755 }.into_user_event(),
706 agent_id: None,
707 trace_id: None,
708 },
709 );
710 assert!(out.contains("Summarizing"));
711 assert!(out.contains("755"));
712 }
713
714 #[test]
715 fn test_render_compression_completed_event() {
716 let out = render_one(
717 true,
718 true,
719 true,
720 RuntimeEvent::UserEvent {
721 session_id: session_id(),
722 event: CompressionEvent::Completed {
723 session_id: 1,
724 tokens_before: 5000,
725 tokens_after: 3500,
726 reduction_pct: 30,
727 msg_count_before: 8,
728 msg_count_after: 6,
729 trigger: CompressionTrigger::Auto,
730 }
731 .into_user_event(),
732 agent_id: None,
733 trace_id: None,
734 },
735 );
736 assert!(out.contains("compressed"));
737 assert!(out.contains("30%"));
738 assert!(out.contains("3500"));
739 }
740
741 #[test]
742 fn test_render_compression_failed_event() {
743 let out = render_one(
744 true,
745 true,
746 true,
747 RuntimeEvent::UserEvent {
748 session_id: session_id(),
749 event: CompressionEvent::Failed {
750 session_id: 1,
751 error: "LLM timeout".into(),
752 trigger: CompressionTrigger::Auto,
753 }
754 .into_user_event(),
755 agent_id: None,
756 trace_id: None,
757 },
758 );
759 assert!(out.contains("failed"));
760 assert!(out.contains("LLM timeout"));
761 }
762
763 #[test]
764 fn test_render_compression_completed_negative_pct() {
765 let out = render_one(
766 true,
767 true,
768 true,
769 RuntimeEvent::UserEvent {
770 session_id: session_id(),
771 event: CompressionEvent::Completed {
772 session_id: 1,
773 tokens_before: 4000,
774 tokens_after: 4200,
775 reduction_pct: -5,
776 msg_count_before: 10,
777 msg_count_after: 8,
778 trigger: CompressionTrigger::Auto,
779 }
780 .into_user_event(),
781 agent_id: None,
782 trace_id: None,
783 },
784 );
785 assert!(out.contains("⚠️"));
786 assert!(out.contains("-5%"));
787 }
788
789 #[test]
792 fn test_finish_turn_contains_duration_and_tool_count() {
793 let out = render_events(
794 true,
795 true,
796 true,
797 &[RuntimeEvent::TextDelta { session_id: session_id(), text: "hi".into(), agent_id: None, trace_id: None }],
798 );
799 assert!(out.contains("elapsed"));
800 assert!(out.contains("tool call"));
801 }
802
803 #[test]
804 fn test_finish_turn_tool_count() {
805 let out = render_events(
806 true,
807 true,
808 true,
809 &[
810 RuntimeEvent::ToolCallStarted {
811 session_id: session_id(),
812 tool_name: "a".into(),
813 args_json: "{}".into(),
814 agent_id: None,
815 trace_id: None,
816 },
817 RuntimeEvent::ToolCallStarted {
818 session_id: session_id(),
819 tool_name: "b".into(),
820 args_json: "{}".into(),
821 agent_id: None,
822 trace_id: None,
823 },
824 RuntimeEvent::ToolCallStarted {
825 session_id: session_id(),
826 tool_name: "c".into(),
827 args_json: "{}".into(),
828 agent_id: None,
829 trace_id: None,
830 },
831 ],
832 );
833 assert!(out.contains("3 tool call"));
834 }
835
836 #[test]
837 fn test_multiple_turns_reset() {
838 let (writer, buf) = SharedWriter::new();
839 {
840 let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
841 r.render(RuntimeEvent::ToolCallStarted {
842 session_id: session_id(),
843 tool_name: "t1".into(),
844 args_json: "{}".into(),
845 agent_id: None,
846 trace_id: None,
847 })
848 .unwrap();
849 r.finish_turn().unwrap();
850 r.render(RuntimeEvent::TextDelta {
851 session_id: session_id(),
852 text: "hello".into(),
853 agent_id: None,
854 trace_id: None,
855 })
856 .unwrap();
857 r.finish_turn().unwrap();
858 }
859 let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
860 assert!(out.contains("1 tool call"));
861 assert!(out.contains("0 tool call"));
862 }
863
864 #[test]
865 fn test_thought_to_text_transition_adds_newline() {
866 let (writer, buf) = SharedWriter::new();
867 {
868 let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
869 r.render(RuntimeEvent::ThoughtDelta {
870 session_id: session_id(),
871 text: "hmm".into(),
872 agent_id: None,
873 trace_id: None,
874 })
875 .unwrap();
876 r.render(RuntimeEvent::TextDelta {
877 session_id: session_id(),
878 text: "hello".into(),
879 agent_id: None,
880 trace_id: None,
881 })
882 .unwrap();
883 }
884 let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
885 assert!(out.contains("hmm"));
886 assert!(out.contains("hello"));
887 }
888
889 #[test]
892 fn test_render_subagent_text_delta() {
893 let out = render_one(
894 true,
895 true,
896 false,
897 RuntimeEvent::TextDelta {
898 session_id: session_id(),
899 text: "found results".into(),
900 agent_id: Some("root/searcher".into()),
901 trace_id: None,
902 },
903 );
904 assert!(out.contains("[root/searcher]"));
905 assert!(out.contains("found results"));
906 }
907
908 #[test]
909 fn test_render_subagent_tool_call() {
910 let out = render_one(
911 true,
912 true,
913 false,
914 RuntimeEvent::ToolCallStarted {
915 session_id: session_id(),
916 tool_name: "search".into(),
917 args_json: r#"{"q":"test"}"#.into(),
918 agent_id: Some("root/worker".into()),
919 trace_id: None,
920 },
921 );
922 assert!(out.contains("[root/worker]"));
923 assert!(out.contains("search"));
924 assert!(out.contains("test"));
925 }
926
927 #[test]
928 fn test_render_subagent_thought_hidden() {
929 let out = render_one(
930 false,
931 true,
932 false,
933 RuntimeEvent::ThoughtDelta {
934 session_id: session_id(),
935 text: "secret plan".into(),
936 agent_id: Some("root/thinker".into()),
937 trace_id: None,
938 },
939 );
940 assert!(!out.contains("secret plan"));
941 }
942
943 #[test]
944 fn test_render_subagent_run_cancelled() {
945 let out = render_one(
946 true,
947 true,
948 false,
949 RuntimeEvent::RunCancelled {
950 session_id: session_id(),
951 agent_id: Some("root/worker".into()),
952 trace_id: None,
953 },
954 );
955 assert!(out.contains("[root/worker]"));
956 assert!(out.contains("Cancelled"));
957 }
958}