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