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            },
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        // Auto Preparing is suppressed — Started event already shows the same info.
612        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    // ── finish_turn tests ──
787
788    #[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    // ── Sub-agent (agent_id) tests ──
887
888    #[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}