Skip to main content

phi_agent/render/
terminal.rs

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
11/// Rich terminal renderer — colors, emoji, formatted output.
12///
13/// Streams AI responses in real-time, displays tool calls with icons, and
14/// shows turn summaries including duration and tool call count.
15pub 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    /// Create a new terminal renderer.
31    ///
32    /// - `show_thinking` — display the LLM's chain-of-thought
33    /// - `show_tool_args` — display tool call arguments inline
34    /// - `color` — enable ANSI color codes
35    /// - `writer` — output destination (usually stdout, can be a WebSocket, etc.)
36    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    /// Write without newline — for streaming text fragments
83    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        // Sub-agent events (agent_id set) get a `[path]` prefix and must not
97        // pollute the parent's assistant-text buffer.
98        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                // Add a blank line after tool completion for readability
165                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                // Guard above already confirmed this is a valid CompressionEvent.
202                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                            // Auto: suppress — Started event already shows the same info.
207                            // Manual (/compact): show since there is no Started event before it.
208                            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                                // Deduplicate: only log every 200 chars to reduce noise.
242                                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    /// A Write impl backed by shared memory, for testing renderers.
326    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    // ── Color tests ──
371
372    #[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    // ── Event rendering tests ──
397
398    #[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        // Auto Preparing is suppressed — Started event already shows the same info.
615        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    // ── finish_turn tests ──
790
791    #[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    // ── Sub-agent (agent_id) tests ──
890
891    #[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}