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