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 },
538 agent_id: None,
539 trace_id: None,
540 },
541 );
542 assert!(out.contains("Delete file"));
543 assert!(out.contains("Destructive"));
544 }
545
546 #[test]
547 fn test_render_plan_updated() {
548 let out = render_one(
549 true,
550 true,
551 true,
552 RuntimeEvent::PlanUpdated {
553 session_id: session_id(),
554 objective: "test plan".into(),
555 explanation: Some("starting work".into()),
556 plan: vec![
557 PlanItem { step: "Step 1".into(), status: PlanStepStatus::Completed },
558 PlanItem { step: "Step 2".into(), status: PlanStepStatus::InProgress },
559 PlanItem { step: "Step 3".into(), status: PlanStepStatus::Pending },
560 ],
561 agent_id: None,
562 trace_id: None,
563 },
564 );
565 assert!(out.contains("Plan Update"));
566 assert!(out.contains("starting work"));
567 assert!(out.contains("✅"));
568 assert!(out.contains("Step 1"));
569 assert!(out.contains("Step 2"));
570 assert!(out.contains("Step 3"));
571 }
572
573 #[test]
574 fn test_render_run_cancelled() {
575 let out = render_one(
576 true,
577 true,
578 true,
579 RuntimeEvent::RunCancelled { session_id: session_id(), agent_id: None, trace_id: None },
580 );
581 assert!(out.contains("Cancelled"));
582 }
583
584 #[test]
585 fn test_render_run_finished_no_output() {
586 let out = render_one(
587 true,
588 true,
589 true,
590 RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None },
591 );
592 assert!(out.is_empty());
593 }
594
595 #[test]
596 fn test_render_user_event_progress_shows_text() {
597 let out = render_one(
598 true,
599 true,
600 true,
601 RuntimeEvent::UserEvent {
602 session_id: session_id(),
603 event: UserEvent::Progress { text: "loading...".into() },
604 agent_id: None,
605 trace_id: None,
606 },
607 );
608 assert!(out.contains("loading..."));
609 }
610
611 #[test]
612 fn test_render_compression_preparing_auto_suppressed() {
613 let out = render_one(
615 true,
616 true,
617 true,
618 RuntimeEvent::UserEvent {
619 session_id: session_id(),
620 event: CompressionEvent::Preparing {
621 session_id: 1,
622 tokens_before: 4200,
623 msg_count: 8,
624 trigger: CompressionTrigger::Auto,
625 }
626 .into_user_event(),
627 agent_id: None,
628 trace_id: None,
629 },
630 );
631 assert!(out.is_empty(), "Auto Preparing should be suppressed, got: {out}");
632 }
633
634 #[test]
635 fn test_render_compression_preparing_manual() {
636 let out = render_one(
637 true,
638 true,
639 true,
640 RuntimeEvent::UserEvent {
641 session_id: session_id(),
642 event: CompressionEvent::Preparing {
643 session_id: 1,
644 tokens_before: 5000,
645 msg_count: 10,
646 trigger: CompressionTrigger::Manual,
647 }
648 .into_user_event(),
649 agent_id: None,
650 trace_id: None,
651 },
652 );
653 assert!(out.contains("Manual compression"));
654 assert!(out.contains("5000"));
655 }
656
657 #[test]
658 fn test_render_compression_started_event() {
659 let out = render_one(
660 true,
661 true,
662 true,
663 RuntimeEvent::UserEvent {
664 session_id: session_id(),
665 event: CompressionEvent::Started {
666 session_id: 1,
667 tokens_before: 5000,
668 msg_count: 8,
669 trigger: CompressionTrigger::Auto,
670 }
671 .into_user_event(),
672 agent_id: None,
673 trace_id: None,
674 },
675 );
676 assert!(out.contains("Compressing"));
677 assert!(out.contains("5000"));
678 }
679
680 #[test]
681 fn test_render_compression_progress_connecting() {
682 let out = render_one(
683 true,
684 true,
685 true,
686 RuntimeEvent::UserEvent {
687 session_id: session_id(),
688 event: CompressionEvent::Progress { session_id: 1, chars: 0 }.into_user_event(),
689 agent_id: None,
690 trace_id: None,
691 },
692 );
693 assert!(out.contains("Connecting"));
694 }
695
696 #[test]
697 fn test_render_compression_progress_summarizing() {
698 let out = render_one(
699 true,
700 true,
701 true,
702 RuntimeEvent::UserEvent {
703 session_id: session_id(),
704 event: CompressionEvent::Progress { session_id: 1, chars: 755 }.into_user_event(),
705 agent_id: None,
706 trace_id: None,
707 },
708 );
709 assert!(out.contains("Summarizing"));
710 assert!(out.contains("755"));
711 }
712
713 #[test]
714 fn test_render_compression_completed_event() {
715 let out = render_one(
716 true,
717 true,
718 true,
719 RuntimeEvent::UserEvent {
720 session_id: session_id(),
721 event: CompressionEvent::Completed {
722 session_id: 1,
723 tokens_before: 5000,
724 tokens_after: 3500,
725 reduction_pct: 30,
726 msg_count_before: 8,
727 msg_count_after: 6,
728 trigger: CompressionTrigger::Auto,
729 }
730 .into_user_event(),
731 agent_id: None,
732 trace_id: None,
733 },
734 );
735 assert!(out.contains("compressed"));
736 assert!(out.contains("30%"));
737 assert!(out.contains("3500"));
738 }
739
740 #[test]
741 fn test_render_compression_failed_event() {
742 let out = render_one(
743 true,
744 true,
745 true,
746 RuntimeEvent::UserEvent {
747 session_id: session_id(),
748 event: CompressionEvent::Failed {
749 session_id: 1,
750 error: "LLM timeout".into(),
751 trigger: CompressionTrigger::Auto,
752 }
753 .into_user_event(),
754 agent_id: None,
755 trace_id: None,
756 },
757 );
758 assert!(out.contains("failed"));
759 assert!(out.contains("LLM timeout"));
760 }
761
762 #[test]
763 fn test_render_compression_completed_negative_pct() {
764 let out = render_one(
765 true,
766 true,
767 true,
768 RuntimeEvent::UserEvent {
769 session_id: session_id(),
770 event: CompressionEvent::Completed {
771 session_id: 1,
772 tokens_before: 4000,
773 tokens_after: 4200,
774 reduction_pct: -5,
775 msg_count_before: 10,
776 msg_count_after: 8,
777 trigger: CompressionTrigger::Auto,
778 }
779 .into_user_event(),
780 agent_id: None,
781 trace_id: None,
782 },
783 );
784 assert!(out.contains("⚠️"));
785 assert!(out.contains("-5%"));
786 }
787
788 #[test]
791 fn test_finish_turn_contains_duration_and_tool_count() {
792 let out = render_events(
793 true,
794 true,
795 true,
796 &[RuntimeEvent::TextDelta { session_id: session_id(), text: "hi".into(), agent_id: None, trace_id: None }],
797 );
798 assert!(out.contains("elapsed"));
799 assert!(out.contains("tool call"));
800 }
801
802 #[test]
803 fn test_finish_turn_tool_count() {
804 let out = render_events(
805 true,
806 true,
807 true,
808 &[
809 RuntimeEvent::ToolCallStarted {
810 session_id: session_id(),
811 tool_name: "a".into(),
812 args_json: "{}".into(),
813 agent_id: None,
814 trace_id: None,
815 },
816 RuntimeEvent::ToolCallStarted {
817 session_id: session_id(),
818 tool_name: "b".into(),
819 args_json: "{}".into(),
820 agent_id: None,
821 trace_id: None,
822 },
823 RuntimeEvent::ToolCallStarted {
824 session_id: session_id(),
825 tool_name: "c".into(),
826 args_json: "{}".into(),
827 agent_id: None,
828 trace_id: None,
829 },
830 ],
831 );
832 assert!(out.contains("3 tool call"));
833 }
834
835 #[test]
836 fn test_multiple_turns_reset() {
837 let (writer, buf) = SharedWriter::new();
838 {
839 let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
840 r.render(RuntimeEvent::ToolCallStarted {
841 session_id: session_id(),
842 tool_name: "t1".into(),
843 args_json: "{}".into(),
844 agent_id: None,
845 trace_id: None,
846 })
847 .unwrap();
848 r.finish_turn().unwrap();
849 r.render(RuntimeEvent::TextDelta {
850 session_id: session_id(),
851 text: "hello".into(),
852 agent_id: None,
853 trace_id: None,
854 })
855 .unwrap();
856 r.finish_turn().unwrap();
857 }
858 let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
859 assert!(out.contains("1 tool call"));
860 assert!(out.contains("0 tool call"));
861 }
862
863 #[test]
864 fn test_thought_to_text_transition_adds_newline() {
865 let (writer, buf) = SharedWriter::new();
866 {
867 let mut r = TerminalRenderer::new(true, true, true, Box::new(writer));
868 r.render(RuntimeEvent::ThoughtDelta {
869 session_id: session_id(),
870 text: "hmm".into(),
871 agent_id: None,
872 trace_id: None,
873 })
874 .unwrap();
875 r.render(RuntimeEvent::TextDelta {
876 session_id: session_id(),
877 text: "hello".into(),
878 agent_id: None,
879 trace_id: None,
880 })
881 .unwrap();
882 }
883 let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
884 assert!(out.contains("hmm"));
885 assert!(out.contains("hello"));
886 }
887
888 #[test]
891 fn test_render_subagent_text_delta() {
892 let out = render_one(
893 true,
894 true,
895 false,
896 RuntimeEvent::TextDelta {
897 session_id: session_id(),
898 text: "found results".into(),
899 agent_id: Some("root/searcher".into()),
900 trace_id: None,
901 },
902 );
903 assert!(out.contains("[root/searcher]"));
904 assert!(out.contains("found results"));
905 }
906
907 #[test]
908 fn test_render_subagent_tool_call() {
909 let out = render_one(
910 true,
911 true,
912 false,
913 RuntimeEvent::ToolCallStarted {
914 session_id: session_id(),
915 tool_name: "search".into(),
916 args_json: r#"{"q":"test"}"#.into(),
917 agent_id: Some("root/worker".into()),
918 trace_id: None,
919 },
920 );
921 assert!(out.contains("[root/worker]"));
922 assert!(out.contains("search"));
923 assert!(out.contains("test"));
924 }
925
926 #[test]
927 fn test_render_subagent_thought_hidden() {
928 let out = render_one(
929 false,
930 true,
931 false,
932 RuntimeEvent::ThoughtDelta {
933 session_id: session_id(),
934 text: "secret plan".into(),
935 agent_id: Some("root/thinker".into()),
936 trace_id: None,
937 },
938 );
939 assert!(!out.contains("secret plan"));
940 }
941
942 #[test]
943 fn test_render_subagent_run_cancelled() {
944 let out = render_one(
945 true,
946 true,
947 false,
948 RuntimeEvent::RunCancelled {
949 session_id: session_id(),
950 agent_id: Some("root/worker".into()),
951 trace_id: None,
952 },
953 );
954 assert!(out.contains("[root/worker]"));
955 assert!(out.contains("Cancelled"));
956 }
957}