Skip to main content

nika_engine/display/
renderer.rs

1//! CliRenderer — append-only event stream renderer.
2//!
3//! Receives Event structs from the runner and prints formatted lines.
4//! NO ANSI cursor movement. Every print is a simple println!().
5
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::Instant;
9
10use colored::Colorize;
11use serde_json::Value;
12
13use crate::display::{colors, icons, DetailLevel};
14use crate::event::EventKind;
15
16/// Accumulated stats for the summary.
17#[derive(Debug, Default)]
18pub struct RunStats {
19    pub tasks_passed: usize,
20    pub tasks_failed: usize,
21    pub tasks_skipped: usize,
22    pub total_input_tokens: u64,
23    pub total_output_tokens: u64,
24    pub total_cache_tokens: u64,
25    pub total_cost: f64,
26    pub ttft_values: Vec<u64>,
27    pub mcp_calls: u32,
28    pub mcp_retries: u32,
29    pub mcp_errors: u32,
30    pub media_stored: u32,
31    pub media_bytes: u64,
32    pub media_dedup: u32,
33    pub artifacts_count: u32,
34    pub artifacts_bytes: u64,
35    pub guardrails_passed: u32,
36    pub guardrails_failed: u32,
37    pub guardrails_escalations: u32,
38    pub structured_attempts: u32,
39    pub structured_success_layer: Option<u8>,
40    pub root_failure: Option<String>,
41    /// Per-task timing: (task_id, verb, start_offset_ms, duration_ms)
42    pub task_timeline: Vec<(String, String, u64, u64)>,
43    /// Per-provider call: (task_id, in, out, cache, ttft_ms, cost)
44    pub provider_calls: Vec<ProviderCallStat>,
45}
46
47#[derive(Debug)]
48pub struct ProviderCallStat {
49    pub task_id: String,
50    pub input_tokens: u64,
51    pub output_tokens: u64,
52    pub cache_tokens: u64,
53    pub ttft_ms: Option<u64>,
54    pub cost: f64,
55}
56
57impl RunStats {
58    /// Apply a single event to accumulate stats.
59    ///
60    /// Centralizes stat accumulation that was previously duplicated in both
61    /// `CliRenderer::render()` and `LiveRenderer::render()`.
62    /// Renderers call this at the top of their render method, then only handle
63    /// display-specific logic (printing, progress bars, etc.) in their match arms.
64    pub fn apply_event(&mut self, event: &crate::event::Event) {
65        match &event.kind {
66            EventKind::ProviderResponded {
67                input_tokens,
68                output_tokens,
69                cache_read_tokens,
70                ttft_ms,
71                cost_usd,
72                ..
73            } => {
74                self.total_input_tokens += input_tokens;
75                self.total_output_tokens += output_tokens;
76                self.total_cache_tokens += cache_read_tokens;
77                self.total_cost += cost_usd;
78                if let Some(t) = ttft_ms {
79                    self.ttft_values.push(*t);
80                }
81                self.provider_calls.push(ProviderCallStat {
82                    task_id: event.kind.task_id().unwrap_or("?").to_string(),
83                    input_tokens: *input_tokens,
84                    output_tokens: *output_tokens,
85                    cache_tokens: *cache_read_tokens,
86                    ttft_ms: *ttft_ms,
87                    cost: *cost_usd,
88                });
89            }
90
91            EventKind::McpError { .. } => {
92                self.mcp_errors += 1;
93            }
94
95            EventKind::McpInvoke { .. } => {
96                self.mcp_calls += 1;
97            }
98
99            EventKind::McpRetry { .. } => {
100                self.mcp_retries += 1;
101            }
102
103            EventKind::GuardrailPassed { .. } => {
104                self.guardrails_passed += 1;
105            }
106
107            EventKind::GuardrailFailed { .. } => {
108                self.guardrails_failed += 1;
109            }
110
111            EventKind::GuardrailEscalation { .. } => {
112                self.guardrails_escalations += 1;
113            }
114
115            EventKind::ArtifactWritten { size, .. } => {
116                self.artifacts_count += 1;
117                self.artifacts_bytes += size;
118            }
119
120            EventKind::MediaStored {
121                size_bytes,
122                deduplicated,
123                ..
124            } => {
125                self.media_stored += 1;
126                self.media_bytes += size_bytes;
127                if *deduplicated {
128                    self.media_dedup += 1;
129                }
130            }
131
132            EventKind::StructuredOutputAttempt { .. } => {
133                self.structured_attempts += 1;
134            }
135
136            EventKind::StructuredOutputSuccess { layer, .. } => {
137                self.structured_success_layer = Some(*layer);
138            }
139
140            EventKind::TaskCompleted { .. } => {
141                self.tasks_passed += 1;
142            }
143
144            EventKind::TaskFailed { task_id, .. } => {
145                self.tasks_failed += 1;
146                if self.root_failure.is_none() {
147                    self.root_failure = Some(task_id.to_string());
148                }
149            }
150
151            EventKind::TaskSkipped { .. } => {
152                self.tasks_skipped += 1;
153            }
154
155            _ => {}
156        }
157    }
158}
159
160pub struct CliRenderer {
161    detail: DetailLevel,
162    start: Instant,
163    pub(crate) stats: RunStats,
164    /// Track which DAG layer each task belongs to (for layer separators)
165    task_layers: HashMap<Arc<str>, usize>,
166    /// Current layer being displayed
167    current_layer: usize,
168    /// Terminal width for layout
169    term_width: u16,
170    /// Track task start times for timeline
171    task_starts: HashMap<String, (u64, String)>,
172    /// Workflow start timestamp for offset calculation
173    workflow_start_ms: u64,
174    /// Last rendered event ID for incremental rendering.
175    /// `None` means no events have been rendered yet (first call renders ALL events).
176    last_rendered_id: Option<u64>,
177}
178
179impl CliRenderer {
180    pub fn new(detail: DetailLevel) -> Self {
181        let term_width = terminal_size::terminal_size()
182            .map(|(w, _)| w.0)
183            .unwrap_or(80);
184
185        Self {
186            detail,
187            start: Instant::now(),
188            stats: RunStats::default(),
189            task_layers: HashMap::new(),
190            current_layer: 0,
191            term_width,
192            task_starts: HashMap::new(),
193            workflow_start_ms: 0,
194            last_rendered_id: None,
195        }
196    }
197
198    pub fn last_rendered_id(&self) -> Option<u64> {
199        self.last_rendered_id
200    }
201
202    /// Set task-to-layer mapping (called after DAG analysis).
203    pub fn set_task_layers(&mut self, layers: HashMap<Arc<str>, usize>) {
204        self.task_layers = layers;
205    }
206
207    /// Format timestamp offset from workflow start.
208    fn ts(&self) -> String {
209        let elapsed = self.start.elapsed().as_secs_f32();
210        format!("{:>6}", format!("+{:.1}s", elapsed))
211            .dimmed()
212            .to_string()
213    }
214
215    pub fn render_new_events(&mut self, events: &[crate::event::Event]) {
216        for event in events {
217            if self.last_rendered_id.is_none_or(|last| event.id > last) {
218                self.render(event);
219                self.last_rendered_id = Some(event.id);
220            }
221        }
222    }
223
224    pub fn render_kind(&mut self, kind: &crate::event::EventKind) {
225        let event = crate::event::Event {
226            id: 0,
227            timestamp_ms: self.start.elapsed().as_millis() as u64,
228            kind: kind.clone(),
229        };
230        self.render(&event);
231    }
232
233    /// Main entry point: render a single event.
234    pub fn render(&mut self, event: &crate::event::Event) {
235        if self.detail.is_json() {
236            // JSON mode: print raw NDJSON
237            match serde_json::to_string(event) {
238                Ok(json) => println!("{}", json),
239                Err(e) => {
240                    // Emit a minimal error event so NDJSON consumers see the gap
241                    eprintln!(
242                        "{{\"error\":\"event_serialization_failed\",\"detail\":\"{}\"}}",
243                        e.to_string().replace('"', "'")
244                    );
245                }
246            }
247            return;
248        }
249
250        // Centralized stat accumulation — covers all ~15 stat-bearing event types
251        self.stats.apply_event(event);
252
253        match &event.kind {
254            // ═══════════════════════════════════════
255            // WORKFLOW LEVEL
256            // ═══════════════════════════════════════
257            EventKind::WorkflowStarted { .. } => {
258                self.workflow_start_ms = event.timestamp_ms;
259                // Header already printed by main.rs
260            }
261            EventKind::WorkflowPaused => {
262                println!("{} {} paused", self.ts(), "⏸".yellow());
263            }
264            EventKind::WorkflowResumed => {
265                println!("{} {} resumed", self.ts(), "▶".green());
266            }
267            EventKind::WorkflowAborted {
268                reason,
269                running_tasks,
270                ..
271            } => {
272                println!(
273                    "{} {} {}",
274                    self.ts(),
275                    "⚠".red().bold(),
276                    "ABORTED".red().bold()
277                );
278                println!(
279                    "{}   {} {}",
280                    " ".repeat(6),
281                    "reason:".dimmed(),
282                    reason.red()
283                );
284                if !running_tasks.is_empty() {
285                    let names: Vec<&str> = running_tasks.iter().map(|s| s.as_ref()).collect();
286                    println!(
287                        "{}   {} {}",
288                        " ".repeat(6),
289                        "running:".dimmed(),
290                        names.join(", ").yellow()
291                    );
292                }
293            }
294
295            // ═══════════════════════════════════════
296            // TASK LEVEL
297            // ═══════════════════════════════════════
298            EventKind::TaskScheduled {
299                task_id,
300                dependencies,
301            } => {
302                // Check if we need a layer separator
303                if self.detail.show_layer_separators() {
304                    if let Some(&layer) = self.task_layers.get(task_id) {
305                        if layer > self.current_layer && self.current_layer > 0 {
306                            println!();
307                            let label = format!(" layer {} ", layer + 1);
308                            let dash = "─ ".dimmed();
309                            let half =
310                                (self.term_width as usize / 4).saturating_sub(label.len() / 2);
311                            println!(
312                                "{}{}{}{}",
313                                " ".repeat(14),
314                                dash.to_string().repeat(half / 2),
315                                label.dimmed(),
316                                dash.to_string().repeat(half / 2)
317                            );
318                            println!();
319                        }
320                        self.current_layer = layer;
321                    }
322                }
323
324                let deps_str = if dependencies.is_empty() {
325                    "—".dimmed().to_string()
326                } else {
327                    dependencies
328                        .iter()
329                        .map(|d| d.as_ref())
330                        .collect::<Vec<_>>()
331                        .join(", ")
332                };
333                // Look up verb for this task — will be filled by TaskStarted
334                let padded_id = format!("{:<14}", task_id);
335                println!(
336                    "{}  {} {} {} {} {}",
337                    self.ts(),
338                    icons::pending(),
339                    " ".normal(), // placeholder — verb not known yet at schedule time
340                    padded_id.bold(),
341                    "scheduled".dimmed(),
342                    format!("deps: {}", deps_str).dimmed()
343                );
344            }
345
346            EventKind::TaskStarted { task_id, verb, .. } => {
347                self.task_starts
348                    .insert(task_id.to_string(), (event.timestamp_ms, verb.to_string()));
349                let padded_id = format!("{:<14}", task_id);
350                println!(
351                    "{}  {} {} {} {}",
352                    self.ts(),
353                    icons::running(),
354                    icons::verb(verb),
355                    padded_id.bold(),
356                    "running".white()
357                );
358            }
359
360            EventKind::TaskCompleted {
361                task_id,
362                output,
363                duration_ms,
364            } => {
365                let dur_secs = *duration_ms as f32 / 1000.0;
366
367                // Look up verb
368                let verb = self
369                    .task_starts
370                    .get(task_id.as_ref())
371                    .map(|(_, v)| v.clone())
372                    .unwrap_or_default();
373
374                // Record timeline
375                if let Some((start, _)) = self.task_starts.get(task_id.as_ref()) {
376                    self.stats.task_timeline.push((
377                        task_id.to_string(),
378                        verb.clone(),
379                        start.saturating_sub(self.workflow_start_ms),
380                        *duration_ms,
381                    ));
382                }
383
384                let padded_id = format!("{:<14}", task_id);
385                println!(
386                    "{}  {} {} {} {}",
387                    self.ts(),
388                    icons::success(),
389                    icons::verb(&verb),
390                    padded_id.bold(),
391                    colors::duration(dur_secs)
392                );
393
394                // Output preview
395                if self.detail.show_previews() {
396                    self.render_output_preview(output);
397                }
398            }
399
400            EventKind::TaskFailed {
401                task_id,
402                error,
403                duration_ms,
404                ..
405            } => {
406                let dur_secs = *duration_ms as f32 / 1000.0;
407                let verb = self
408                    .task_starts
409                    .get(task_id.as_ref())
410                    .map(|(_, v)| v.clone())
411                    .unwrap_or_default();
412
413                // Record timeline (failed tasks should appear in Gantt chart too)
414                if let Some((start, _)) = self.task_starts.get(task_id.as_ref()) {
415                    self.stats.task_timeline.push((
416                        task_id.to_string(),
417                        verb.clone(),
418                        start.saturating_sub(self.workflow_start_ms),
419                        *duration_ms,
420                    ));
421                }
422
423                let padded_id = format!("{:<14}", task_id);
424                println!(
425                    "{}  {} {} {} {}",
426                    self.ts(),
427                    icons::failed(),
428                    icons::verb(&verb),
429                    padded_id.bold().red(),
430                    colors::duration(dur_secs)
431                );
432                println!(
433                    "{}  {} {} {}",
434                    " ".repeat(6),
435                    "│".dimmed(),
436                    "error".red(),
437                    error.red()
438                );
439            }
440
441            EventKind::TaskSkipped { task_id, reason } => {
442                let padded_id = format!("{:<14}", task_id);
443                println!(
444                    "{}  {} {} {} {}",
445                    self.ts(),
446                    "\u{2298}".dimmed(),
447                    " ".normal(),
448                    padded_id.dimmed(),
449                    format!("skipped \u{2014} {}", reason).dimmed()
450                );
451            }
452
453            // ═══════════════════════════════════════
454            // FINE-GRAINED
455            // ═══════════════════════════════════════
456            EventKind::TemplateResolved {
457                task_id: _,
458                template,
459                result,
460            } => {
461                if self.detail.show_template_events() {
462                    println!(
463                        "{}",
464                        super::format_event::fmt_template_resolved(template, result)
465                    );
466                }
467            }
468
469            EventKind::ProviderCalled {
470                task_id: _,
471                provider,
472                model,
473                prompt_len,
474            } => {
475                if self.detail.show_sub_events() {
476                    println!(
477                        "{}",
478                        super::format_event::fmt_provider_called(provider, model, *prompt_len)
479                    );
480                }
481            }
482
483            EventKind::ProviderResponded {
484                input_tokens,
485                output_tokens,
486                cache_read_tokens,
487                ttft_ms,
488                cost_usd,
489                ..
490            } => {
491                if self.detail.show_sub_events() {
492                    println!(
493                        "{}",
494                        super::format_event::fmt_provider_responded(
495                            *input_tokens,
496                            *output_tokens,
497                            *cache_read_tokens,
498                            *ttft_ms,
499                        )
500                    );
501                    if self.detail.show_sparklines() {
502                        println!(
503                            "{}",
504                            super::format_event::fmt_provider_sparkline(
505                                *output_tokens,
506                                *input_tokens,
507                                *cost_usd,
508                            )
509                        );
510                    }
511                }
512            }
513
514            // ═══════════════════════════════════════
515            // CONTEXT
516            // ═══════════════════════════════════════
517            EventKind::ContextAssembled {
518                task_id: _,
519                sources,
520                total_tokens,
521                budget_used_pct,
522                truncated: _,
523                ..
524            } => {
525                if self.detail.show_sub_events() {
526                    println!(
527                        "{}",
528                        super::format_event::fmt_context_assembled(
529                            sources.len(),
530                            *total_tokens,
531                            *budget_used_pct,
532                        )
533                    );
534                }
535            }
536
537            // ═══════════════════════════════════════
538            // MCP
539            // ═══════════════════════════════════════
540            EventKind::McpConnected { server_name } => {
541                if self.detail.show_sub_events() {
542                    println!("{}", super::format_event::fmt_mcp_connected(server_name));
543                }
544            }
545
546            EventKind::McpError { server_name, error } => {
547                println!("{}", super::format_event::fmt_mcp_error(server_name, error));
548            }
549
550            EventKind::McpInvoke {
551                call_id,
552                mcp_server,
553                tool,
554                resource,
555                ..
556            } => {
557                if self.detail.show_sub_events() {
558                    println!(
559                        "{}",
560                        super::format_event::fmt_mcp_invoke(
561                            mcp_server,
562                            tool.as_deref(),
563                            resource.as_deref(),
564                            call_id,
565                        )
566                    );
567                }
568            }
569
570            EventKind::McpResponse {
571                call_id,
572                output_len,
573                duration_ms,
574                cached,
575                is_error,
576                ..
577            } => {
578                if self.detail.show_sub_events() {
579                    println!(
580                        "{}",
581                        super::format_event::fmt_mcp_response(
582                            call_id,
583                            *output_len,
584                            *duration_ms,
585                            *cached,
586                            *is_error,
587                        )
588                    );
589                }
590            }
591
592            EventKind::McpRetry {
593                operation,
594                attempt,
595                max_attempts,
596                error,
597                ..
598            } => {
599                println!(
600                    "{}",
601                    super::format_event::fmt_mcp_retry(operation, *attempt, *max_attempts, error,)
602                );
603            }
604
605            // ═══════════════════════════════════════
606            // AGENT
607            // ═══════════════════════════════════════
608            EventKind::AgentStart {
609                task_id: _,
610                max_turns,
611                mcp_servers,
612            } => {
613                if self.detail.show_sub_events() {
614                    println!(
615                        "{}",
616                        super::format_event::fmt_agent_start(*max_turns, mcp_servers)
617                    );
618                }
619            }
620
621            EventKind::AgentTurn {
622                task_id: _,
623                turn_index,
624                kind,
625                metadata,
626            } => {
627                if self.detail.show_sub_events() {
628                    println!("{}", super::format_event::fmt_agent_turn(*turn_index, kind));
629                    if let Some(meta) = metadata {
630                        if meta.stop_reason == "tool_use" {
631                            println!("{}", super::format_event::fmt_agent_turn_tool_use());
632                        }
633                    }
634                }
635            }
636
637            EventKind::AgentComplete {
638                task_id: _,
639                turns,
640                stop_reason,
641            } => {
642                if self.detail.show_sub_events() {
643                    println!(
644                        "{}",
645                        super::format_event::fmt_agent_complete(*turns, stop_reason)
646                    );
647                }
648            }
649
650            EventKind::AgentSpawned {
651                parent_task_id: _,
652                child_task_id,
653                depth,
654            } => {
655                if self.detail.show_sub_events() {
656                    println!(
657                        "{}",
658                        super::format_event::fmt_agent_spawned(child_task_id, *depth)
659                    );
660                }
661            }
662
663            // ═══════════════════════════════════════
664            // GUARDRAILS
665            // ═══════════════════════════════════════
666            EventKind::GuardrailPassed {
667                guardrail_type,
668                description,
669                ..
670            } => {
671                if self.detail.show_sub_events() {
672                    println!(
673                        "{}",
674                        super::format_event::fmt_guardrail_passed(guardrail_type, description)
675                    );
676                }
677            }
678
679            EventKind::GuardrailFailed {
680                guardrail_type,
681                message,
682                ..
683            } => {
684                println!(
685                    "{}",
686                    super::format_event::fmt_guardrail_failed(guardrail_type, message)
687                );
688            }
689
690            EventKind::GuardrailEscalation {
691                severity, message, ..
692            } => {
693                println!(
694                    "{}",
695                    super::format_event::fmt_guardrail_escalation(severity, message)
696                );
697            }
698
699            // ═══════════════════════════════════════
700            // BUILTIN
701            // ═══════════════════════════════════════
702            EventKind::Log { level, message, .. } => {
703                println!(
704                    "{}",
705                    super::format_event::fmt_log(&self.ts(), level, message)
706                );
707            }
708
709            EventKind::Custom { name, payload, .. } => {
710                if self.detail.show_sub_events() {
711                    println!(
712                        "{}",
713                        super::format_event::fmt_custom(&self.ts(), name, payload)
714                    );
715                }
716            }
717
718            // ═══════════════════════════════════════
719            // ARTIFACTS
720            // ═══════════════════════════════════════
721            EventKind::ArtifactWritten {
722                path, size, format, ..
723            } => {
724                if self.detail.show_sub_events() {
725                    println!(
726                        "{}",
727                        super::format_event::fmt_artifact_written(path, *size, format)
728                    );
729                }
730            }
731
732            EventKind::ArtifactFailed { path, reason, .. } => {
733                println!("{}", super::format_event::fmt_artifact_failed(path, reason));
734            }
735
736            // ═══════════════════════════════════════
737            // MEDIA
738            // ═══════════════════════════════════════
739            EventKind::MediaExtracted {
740                task_id: _,
741                block_count,
742                content_types,
743            } => {
744                if self.detail.show_sub_events() {
745                    println!(
746                        "{}",
747                        super::format_event::fmt_media_extracted(*block_count, content_types)
748                    );
749                }
750            }
751
752            EventKind::MediaStored {
753                hash,
754                path,
755                size_bytes,
756                verified,
757                deduplicated,
758                pipeline_ms,
759                ..
760            } => {
761                if self.detail.show_sub_events() {
762                    println!(
763                        "{}",
764                        super::format_event::fmt_media_stored(*size_bytes, path, hash)
765                    );
766                    if self.detail.show_previews() {
767                        println!(
768                            "{}",
769                            super::format_event::fmt_media_stored_detail(
770                                *deduplicated,
771                                *verified,
772                                *pipeline_ms,
773                            )
774                        );
775                    }
776                }
777            }
778
779            EventKind::MediaProcessed { .. } => {
780                // Grouped into MediaStored line — no separate output
781            }
782
783            EventKind::MediaStoreFailed {
784                hash: _, reason, ..
785            } => {
786                println!("{}", super::format_event::fmt_media_store_failed(reason));
787            }
788
789            EventKind::MediaIntegrityCheck { .. } => {
790                // Stored for summary
791            }
792
793            EventKind::MediaCleanup { .. } => {
794                // Stored for summary
795            }
796
797            // ═══════════════════════════════════════
798            // STRUCTURED OUTPUT
799            // ═══════════════════════════════════════
800            EventKind::StructuredOutputAttempt {
801                layer,
802                layer_name,
803                success,
804                error,
805                ..
806            } => {
807                if self.detail.show_sub_events() {
808                    println!(
809                        "{}",
810                        super::format_event::fmt_structured_output_attempt(
811                            *layer,
812                            layer_name,
813                            *success,
814                            error.as_deref(),
815                        )
816                    );
817                }
818            }
819
820            EventKind::StructuredOutputSuccess { .. } => {
821                // Stats handled by apply_event()
822            }
823
824            // ═══════════════════════════════════════
825            // VISION
826            // ═══════════════════════════════════════
827            EventKind::VisionContentResolved {
828                image_count,
829                total_bytes,
830                resolve_ms,
831                ..
832            } => {
833                if self.detail.show_sub_events() {
834                    println!(
835                        "{}",
836                        super::format_event::fmt_vision_content_resolved(
837                            *image_count,
838                            *total_bytes,
839                            *resolve_ms,
840                        )
841                    );
842                }
843            }
844
845            // ═══════════════════════════════════════
846            // HTTP
847            // ═══════════════════════════════════════
848            EventKind::HttpRequest { method, url, .. } => {
849                if self.detail.show_sub_events() {
850                    println!("{}", super::format_event::fmt_http_request(method, url));
851                }
852            }
853
854            EventKind::HttpResponse {
855                status_code,
856                content_type,
857                content_length,
858                elapsed_ms,
859                ..
860            } => {
861                if self.detail.show_sub_events() {
862                    println!(
863                        "{}",
864                        super::format_event::fmt_http_response(
865                            *status_code,
866                            content_type.as_deref(),
867                            *content_length,
868                            *elapsed_ms,
869                        )
870                    );
871                }
872            }
873
874            // ═══════════════════════════════════════
875            // FOR-EACH
876            // ═══════════════════════════════════════
877            EventKind::ForEachCompleted {
878                task_id,
879                total,
880                succeeded,
881                failed,
882                ..
883            } => {
884                if self.detail.show_sub_events() {
885                    println!(
886                        "{}",
887                        super::format_event::fmt_for_each_completed(
888                            task_id, *total, *succeeded, *failed,
889                        )
890                    );
891                }
892            }
893
894            // ═══════════════════════════════════════
895            // EXEC
896            // ═══════════════════════════════════════
897            EventKind::ExecCompleted {
898                exit_code,
899                duration_ms,
900                ..
901            } => {
902                if self.detail.show_sub_events() {
903                    println!(
904                        "{}",
905                        super::format_event::fmt_exec_completed(*exit_code, *duration_ms)
906                    );
907                }
908            }
909
910            // ═══════════════════════════════════════
911            // POLICY
912            // ═══════════════════════════════════════
913            EventKind::PolicyBlocked {
914                policy_type,
915                reason,
916                ..
917            } => {
918                println!(
919                    "{}",
920                    super::format_event::fmt_policy_blocked(&self.ts(), policy_type, reason)
921                );
922            }
923
924            // ═══════════════════════════════════════
925            // FETCH RETRY (always show)
926            // ═══════════════════════════════════════
927            EventKind::FetchRetry {
928                url,
929                attempt,
930                max_attempts,
931                status_code,
932                backoff_ms,
933                ..
934            } => {
935                println!(
936                    "{}",
937                    super::format_event::fmt_fetch_retry(
938                        url,
939                        *attempt,
940                        *max_attempts,
941                        *status_code,
942                        *backoff_ms,
943                    )
944                );
945            }
946
947            // ═══════════════════════════════════════
948            // BOOT (always show)
949            // ═══════════════════════════════════════
950            EventKind::BootPhaseCompleted {
951                phase,
952                success,
953                duration_ms,
954                warnings,
955            } => {
956                println!(
957                    "{}",
958                    super::format_event::fmt_boot_phase(phase, *success, *duration_ms, warnings,)
959                );
960            }
961
962            // ═══════════════════════════════════════
963            // NATIVE MODEL
964            // ═══════════════════════════════════════
965            EventKind::NativeModelLoaded {
966                model,
967                kind,
968                duration_ms,
969                is_vision,
970                ..
971            } => {
972                if self.detail.show_sub_events() {
973                    println!(
974                        "{}",
975                        super::format_event::fmt_native_model_loaded(
976                            model,
977                            kind,
978                            *duration_ms,
979                            *is_vision,
980                        )
981                    );
982                }
983            }
984
985            // ═══════════════════════════════════════
986            // BINDING EVENTS
987            // ═══════════════════════════════════════
988            EventKind::BindingDefaultApplied { alias, path, .. } => {
989                if self.detail.show_sub_events() {
990                    println!("{}", super::format_event::fmt_binding_default(alias, path));
991                }
992            }
993
994            EventKind::BindingTransformApplied {
995                alias,
996                transform_chain,
997                ..
998            } => {
999                if self.detail.show_sub_events() {
1000                    println!(
1001                        "{}",
1002                        super::format_event::fmt_binding_transform(alias, transform_chain)
1003                    );
1004                }
1005            }
1006
1007            EventKind::BindingEnvResolved {
1008                var_name, found, ..
1009            } => {
1010                if self.detail.show_sub_events() {
1011                    println!("{}", super::format_event::fmt_binding_env(var_name, *found));
1012                }
1013            }
1014
1015            // ═══════════════════════════════════════
1016            // DECOMPOSE
1017            // ═══════════════════════════════════════
1018            EventKind::DecomposeStarted {
1019                task_id, strategy, ..
1020            } => {
1021                if self.detail.show_sub_events() {
1022                    println!(
1023                        "{}",
1024                        super::format_event::fmt_decompose_started(task_id, strategy)
1025                    );
1026                }
1027            }
1028
1029            EventKind::DecomposeCompleted {
1030                task_id,
1031                item_count,
1032                duration_ms,
1033                ..
1034            } => {
1035                if self.detail.show_sub_events() {
1036                    println!(
1037                        "{}",
1038                        super::format_event::fmt_decompose_completed(
1039                            task_id,
1040                            *item_count,
1041                            *duration_ms,
1042                        )
1043                    );
1044                }
1045            }
1046
1047            // ═══════════════════════════════════════
1048            // FOR-EACH STARTED
1049            // ═══════════════════════════════════════
1050            EventKind::ForEachStarted {
1051                task_id,
1052                item_count,
1053                concurrency,
1054                ..
1055            } => {
1056                if self.detail.show_sub_events() {
1057                    println!(
1058                        "{}",
1059                        super::format_event::fmt_for_each_started(
1060                            task_id,
1061                            *item_count,
1062                            *concurrency,
1063                        )
1064                    );
1065                }
1066            }
1067
1068            // ═══════════════════════════════════════
1069            // PROVIDER INITIALIZED
1070            // ═══════════════════════════════════════
1071            EventKind::ProviderInitialized {
1072                provider,
1073                model,
1074                cached,
1075            } => {
1076                if self.detail.show_sub_events() {
1077                    println!(
1078                        "{}",
1079                        super::format_event::fmt_provider_initialized(provider, model, *cached,)
1080                    );
1081                }
1082            }
1083
1084            // ═══════════════════════════════════════
1085            // BUILTIN TOOL INVOKED
1086            // ═══════════════════════════════════════
1087            EventKind::BuiltinToolInvoked {
1088                tool_name,
1089                duration_ms,
1090                success,
1091                ..
1092            } => {
1093                if self.detail.show_sub_events() {
1094                    println!(
1095                        "{}",
1096                        super::format_event::fmt_builtin_tool_invoked(
1097                            tool_name,
1098                            *duration_ms,
1099                            *success,
1100                        )
1101                    );
1102                }
1103            }
1104
1105            // ═══════════════════════════════════════
1106            // EXTRACT APPLIED
1107            // ═══════════════════════════════════════
1108            EventKind::ExtractApplied {
1109                mode,
1110                input_len,
1111                output_len,
1112                ..
1113            } => {
1114                if self.detail.show_sub_events() {
1115                    println!(
1116                        "{}",
1117                        super::format_event::fmt_extract_applied(mode, *input_len, *output_len,)
1118                    );
1119                }
1120            }
1121
1122            // Catch-all for WorkflowCompleted/WorkflowFailed (handled by summary)
1123            _ => {}
1124        }
1125    }
1126
1127    /// Render the output preview box with syntax highlighting.
1128    fn render_output_preview(&self, output: &Value) {
1129        for line in format_output_preview(output, self.term_width) {
1130            println!("{}", line);
1131        }
1132    }
1133
1134    pub fn render_quiet_summary(&self, total_duration_ms: u64) {
1135        super::summary::print_run_quiet_summary(&self.stats, total_duration_ms);
1136    }
1137
1138    /// Render the full summary footer.
1139    pub fn render_summary(&self, total_duration_ms: u64, trace_path: Option<&str>) {
1140        super::summary::print_run_summary(&self.stats, self.detail, total_duration_ms, trace_path);
1141    }
1142}
1143
1144// ═══════════════════════════════════════
1145// HELPERS
1146// ═══════════════════════════════════════
1147
1148/// Format bytes: 1234 → "1.2 KB", 1234567 → "1.2 MB"
1149pub(crate) fn format_bytes(bytes: u64) -> String {
1150    if bytes < 1024 {
1151        format!("{} B", bytes)
1152    } else if bytes < 1024 * 1024 {
1153        format!("{:.1} KB", bytes as f64 / 1024.0)
1154    } else {
1155        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
1156    }
1157}
1158
1159use super::colors::{floor_char_boundary, stripped_len};
1160
1161/// Format output preview as a mini box with syntax-highlighted content.
1162///
1163/// Returns a Vec of pre-formatted lines (with ANSI colors and box characters)
1164/// ready for printing. Returns empty Vec if output is null/empty.
1165pub(crate) fn format_output_preview(output: &Value, term_width: u16) -> Vec<String> {
1166    // Use Cow to avoid cloning large LLM output strings (often 10KB+)
1167    // when we only need the first few lines for the preview box.
1168    let owned;
1169    let text: &str = match output {
1170        Value::String(s) => s.as_str(),
1171        _ => {
1172            owned = serde_json::to_string_pretty(output).unwrap_or_default();
1173            &owned
1174        }
1175    };
1176
1177    if text.is_empty() || text == "null" {
1178        return Vec::new();
1179    }
1180
1181    let is_json = text.starts_with('{') || text.starts_with('[');
1182    let is_markdown = text.starts_with('#') || text.contains("\n## ");
1183
1184    let max_width = (term_width as usize).min(72).saturating_sub(16);
1185    let dashes = "\u{254c}".repeat(max_width);
1186    let size_label = format!("{} ch", text.chars().count());
1187    let padding = max_width.saturating_sub(size_label.len() + 1);
1188
1189    let mut lines = Vec::new();
1190
1191    // Top border
1192    lines.push(format!(
1193        "{}     {} {}{}{}",
1194        " ".repeat(6),
1195        "\u{2502}".dimmed(),
1196        "\u{256d}\u{254c}".dimmed(),
1197        dashes.dimmed(),
1198        "\u{256e}".dimmed()
1199    ));
1200
1201    // Content lines
1202    let preview_lines: Vec<String> = if is_json {
1203        vec![colors::json_preview(&text.replace('\n', " "), max_width)]
1204    } else if is_markdown {
1205        colors::markdown_preview(text, 4)
1206            .into_iter()
1207            .map(|l| {
1208                if stripped_len(&l) > max_width {
1209                    let end = floor_char_boundary(&l, max_width - 1);
1210                    format!("{}\u{2026}", &l[..end])
1211                } else {
1212                    l
1213                }
1214            })
1215            .collect()
1216    } else {
1217        text.lines()
1218            .take(2)
1219            .map(|l| {
1220                if l.len() > max_width {
1221                    let end = floor_char_boundary(l, max_width - 1);
1222                    format!("{}\u{2026}", &l[..end])
1223                } else {
1224                    l.to_string()
1225                }
1226            })
1227            .collect()
1228    };
1229
1230    for line in &preview_lines {
1231        let pad = max_width.saturating_sub(stripped_len(line));
1232        lines.push(format!(
1233            "{}     {} {} {}{} {}",
1234            " ".repeat(6),
1235            "\u{2502}".dimmed(),
1236            "\u{2502}".dimmed(),
1237            line,
1238            " ".repeat(pad),
1239            "\u{2502}".dimmed()
1240        ));
1241    }
1242
1243    // Bottom border
1244    lines.push(format!(
1245        "{}     {} {}{} {} {}",
1246        " ".repeat(6),
1247        "\u{2502}".dimmed(),
1248        "\u{2570}\u{254c}".dimmed(),
1249        "\u{254c}".repeat(padding).dimmed(),
1250        size_label.dimmed(),
1251        "\u{254c}\u{256f}".dimmed()
1252    ));
1253
1254    lines
1255}