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