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