Skip to main content

yarli_cli/stream/
renderer.rs

1//! Stream mode renderer using ratatui `Viewport::Inline`.
2//!
3//! Bottom N lines are a live status area with braille spinners.
4//! Completed transitions are pushed above the viewport via `insert_before()`
5//! into native terminal scrollback (copy-pasteable). Section 30.
6
7use std::collections::HashMap;
8use std::io::{self, Stdout, Write};
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use ratatui::backend::CrosstermBackend;
13use ratatui::style::Style;
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Paragraph, Widget};
16use ratatui::{Terminal, TerminalOptions, Viewport};
17
18use crate::yarli_core::domain::CancellationProvenance;
19use crate::yarli_core::domain::TaskId;
20use crate::yarli_core::entities::continuation::TaskHealthAction;
21use crate::yarli_core::explain::DeteriorationTrend;
22use crate::yarli_core::fsm::task::TaskState;
23
24use super::events::{StreamEvent, TaskView};
25use super::spinner::{Spinner, GLYPH_BLOCKED, GLYPH_COMPLETE, GLYPH_FAILED, GLYPH_PENDING};
26use super::style::Tier;
27
28/// Default number of lines for the inline viewport.
29const DEFAULT_VIEWPORT_HEIGHT: u16 = 8;
30const RUN_ID_DISPLAY_LEN: usize = 12;
31const TRANSIENT_STATUS_EMIT_SECS: i64 = 30;
32
33/// Configuration for the stream renderer.
34#[derive(Debug, Clone)]
35pub struct StreamConfig {
36    /// Height of the inline viewport in lines.
37    pub viewport_height: u16,
38    /// When true, stream command output lines to scrollback.
39    pub verbose_output: bool,
40}
41
42impl Default for StreamConfig {
43    fn default() -> Self {
44        Self {
45            viewport_height: DEFAULT_VIEWPORT_HEIGHT,
46            verbose_output: false,
47        }
48    }
49}
50
51/// The stream mode renderer.
52///
53/// Manages an inline viewport at the bottom of the terminal showing
54/// active task status with spinners, and pushes completed transitions
55/// to the native scrollback above.
56pub struct StreamRenderer {
57    terminal: Terminal<CrosstermBackend<Stdout>>,
58    config: StreamConfig,
59    /// Active tasks being tracked in the viewport.
60    tasks: HashMap<TaskId, TaskView>,
61    /// Latest known lifecycle state for each known task in the run.
62    task_states: HashMap<TaskId, TaskState>,
63    /// Insertion order of task IDs for stable rendering.
64    task_order: Vec<TaskId>,
65    /// Per-task spinners.
66    spinners: HashMap<TaskId, Spinner>,
67    /// Current "Why Not Done?" summary.
68    explain_summary: Option<String>,
69    /// Transient status message (shown only in viewport).
70    transient_status: Option<String>,
71    /// Last time we emitted a transient status line to scrollback.
72    last_transient_status_emit_at: Option<DateTime<Utc>>,
73}
74
75impl StreamRenderer {
76    /// Create a new stream renderer with inline viewport.
77    pub fn new(config: StreamConfig) -> io::Result<Self> {
78        let backend = CrosstermBackend::new(io::stdout());
79        let terminal = Terminal::with_options(
80            backend,
81            TerminalOptions {
82                viewport: Viewport::Inline(config.viewport_height),
83            },
84        )?;
85
86        Ok(Self {
87            terminal,
88            config,
89            tasks: HashMap::new(),
90            task_states: HashMap::new(),
91            task_order: Vec::new(),
92            spinners: HashMap::new(),
93            explain_summary: None,
94            transient_status: None,
95            last_transient_status_emit_at: None,
96        })
97    }
98
99    /// Process a stream event: update state, push scrollback, refresh viewport.
100    pub fn handle_event(&mut self, event: StreamEvent) -> io::Result<()> {
101        match event {
102            StreamEvent::TaskDiscovered {
103                task_id,
104                task_name,
105                depends_on,
106            } => {
107                let blocked_by = if depends_on.is_empty() {
108                    None
109                } else {
110                    Some(depends_on.join(", "))
111                };
112                if let std::collections::hash_map::Entry::Vacant(e) = self.tasks.entry(task_id) {
113                    self.task_order.push(task_id);
114                    e.insert(TaskView {
115                        task_id,
116                        name: task_name,
117                        state: TaskState::TaskOpen,
118                        elapsed: None,
119                        last_output_line: None,
120                        blocked_by,
121                        worker_id: None,
122                    });
123                }
124                self.task_states
125                    .entry(task_id)
126                    .or_insert(TaskState::TaskOpen);
127            }
128            StreamEvent::TaskTransition {
129                task_id,
130                task_name,
131                from,
132                to,
133                elapsed,
134                exit_code,
135                detail,
136                at,
137            } => {
138                self.handle_task_transition(
139                    task_id,
140                    &task_name,
141                    from,
142                    to,
143                    elapsed,
144                    exit_code,
145                    detail.as_deref(),
146                    at,
147                )?;
148            }
149            StreamEvent::RunTransition {
150                run_id,
151                from,
152                to,
153                reason,
154                at,
155            } => {
156                let progress = self.progress_snapshot();
157                self.push_run_transition(run_id, from, to, reason.as_deref(), at, progress)?;
158            }
159            StreamEvent::RunStarted {
160                run_id,
161                objective,
162                at,
163            } => {
164                self.push_run_started(run_id, &objective, at)?;
165            }
166            StreamEvent::CommandOutput {
167                task_id,
168                task_name,
169                line,
170            } => {
171                if self.config.verbose_output {
172                    let output_line = Line::from(vec![
173                        Span::styled(format!("  [{task_name}] "), Tier::Background.style()),
174                        Span::styled(line.clone(), Tier::Contextual.style()),
175                    ]);
176                    self.terminal.insert_before(1, |buf| {
177                        Paragraph::new(output_line).render(buf.area, buf);
178                    })?;
179                }
180                if let Some(view) = self.tasks.get_mut(&task_id) {
181                    view.last_output_line = Some(line);
182                }
183            }
184            StreamEvent::TransientStatus { message } => {
185                if should_emit_transient_status_line(
186                    self.last_transient_status_emit_at,
187                    &message,
188                    Utc::now(),
189                ) {
190                    let progress = self.progress_snapshot();
191                    self.push_transient_status_line(&message, Utc::now(), progress)?;
192                }
193                self.transient_status = Some(message);
194            }
195            StreamEvent::ExplainUpdate { summary } => {
196                self.explain_summary = Some(summary);
197            }
198            StreamEvent::TaskWorker { task_id, worker_id } => {
199                if let Some(view) = self.tasks.get_mut(&task_id) {
200                    view.worker_id = Some(worker_id);
201                }
202            }
203            StreamEvent::RunExited { payload } => {
204                self.push_continuation_summary(&payload)?;
205            }
206            StreamEvent::Tick => {
207                for spinner in self.spinners.values_mut() {
208                    spinner.tick();
209                }
210            }
211        }
212
213        self.draw_viewport()?;
214        // Flush stdout immediately so state transitions appear without delay.
215        io::stdout().flush()?;
216        Ok(())
217    }
218
219    /// Handle a task state transition: push to scrollback and update viewport state.
220    #[allow(clippy::too_many_arguments)]
221    fn handle_task_transition(
222        &mut self,
223        task_id: TaskId,
224        task_name: &str,
225        from: TaskState,
226        to: TaskState,
227        elapsed: Option<Duration>,
228        exit_code: Option<i32>,
229        detail: Option<&str>,
230        at: DateTime<Utc>,
231    ) -> io::Result<()> {
232        self.task_states.insert(task_id, to);
233        let progress = self.progress_snapshot();
234
235        // Push transition line to scrollback (permanent, copy-pasteable).
236        self.push_task_transition(
237            task_id, task_name, from, to, elapsed, exit_code, detail, at, progress,
238        )?;
239
240        if to.is_terminal() {
241            // Remove from active viewport.
242            self.tasks.remove(&task_id);
243            self.task_order.retain(|id| *id != task_id);
244            self.spinners.remove(&task_id);
245        } else {
246            // Insert into task_order if new.
247            if let std::collections::hash_map::Entry::Vacant(e) = self.tasks.entry(task_id) {
248                self.task_order.push(task_id);
249                e.insert(TaskView {
250                    task_id,
251                    name: task_name.to_string(),
252                    state: to,
253                    elapsed,
254                    last_output_line: None,
255                    blocked_by: None,
256                    worker_id: None,
257                });
258            } else {
259                let view = self.tasks.get_mut(&task_id).unwrap();
260                view.state = to;
261                view.elapsed = elapsed;
262            }
263
264            if to == TaskState::TaskExecuting {
265                self.spinners.entry(task_id).or_default();
266            }
267        }
268
269        Ok(())
270    }
271
272    /// Push a task transition line above the viewport into scrollback.
273    ///
274    /// Format (Section 33):
275    /// `14:32:01 ▸ task/build  EXECUTING → COMPLETE  (34.2s, exit 0)`
276    #[allow(clippy::too_many_arguments)]
277    fn push_task_transition(
278        &mut self,
279        _task_id: TaskId,
280        task_name: &str,
281        from: TaskState,
282        to: TaskState,
283        elapsed: Option<Duration>,
284        exit_code: Option<i32>,
285        detail: Option<&str>,
286        at: DateTime<Utc>,
287        progress: ProgressSnapshot,
288    ) -> io::Result<()> {
289        let tier = tier_for_task_state(to);
290        let time_str = at.format("%H:%M:%S").to_string();
291
292        let mut spans = vec![
293            Span::styled(time_str, Tier::Contextual.style()),
294            Span::styled(" ▸ ", Tier::Background.style()),
295            Span::styled(format!("task/{:<16}", task_name), tier.style()),
296            Span::styled(format!("{:?}", from), Tier::Contextual.style()),
297            Span::styled(" → ", Tier::Background.style()),
298            Span::styled(format!("{:?}", to), tier.style()),
299        ];
300
301        // Append timing/exit info.
302        let mut meta_parts = Vec::new();
303        if let Some(d) = elapsed {
304            meta_parts.push(format_duration(d));
305        }
306        if let Some(code) = exit_code {
307            meta_parts.push(format!("exit {code}"));
308        }
309        if let Some(d) = detail {
310            meta_parts.push(d.to_string());
311        }
312        if !meta_parts.is_empty() {
313            spans.push(Span::styled(
314                format!("  ({})", meta_parts.join(", ")),
315                Tier::Contextual.style(),
316            ));
317        }
318        spans.push(Span::styled(
319            format!("  progress {}", format_ascii_progress(progress, 20)),
320            Tier::Contextual.style(),
321        ));
322
323        let line = Line::from(spans);
324
325        self.terminal.insert_before(1, |buf| {
326            Paragraph::new(line).render(buf.area, buf);
327        })?;
328
329        Ok(())
330    }
331
332    /// Push a "run started" banner to scrollback.
333    fn push_run_started(
334        &mut self,
335        run_id: uuid::Uuid,
336        objective: &str,
337        at: DateTime<Utc>,
338    ) -> io::Result<()> {
339        let time_str = at.format("%H:%M:%S").to_string();
340        let display_id = display_run_id(run_id);
341
342        let spans = vec![
343            Span::styled(time_str, Tier::Contextual.style()),
344            Span::styled(" ▸ ", Tier::Background.style()),
345            Span::styled(format!("run/{display_id}"), Tier::Active.style()),
346            Span::styled(format!(" started: {objective}"), Tier::Contextual.style()),
347        ];
348
349        let line = Line::from(spans);
350
351        self.terminal.insert_before(1, |buf| {
352            Paragraph::new(line).render(buf.area, buf);
353        })?;
354
355        Ok(())
356    }
357
358    /// Push a run transition line to scrollback.
359    fn push_run_transition(
360        &mut self,
361        run_id: uuid::Uuid,
362        from: crate::yarli_core::fsm::run::RunState,
363        to: crate::yarli_core::fsm::run::RunState,
364        reason: Option<&str>,
365        at: DateTime<Utc>,
366        progress: ProgressSnapshot,
367    ) -> io::Result<()> {
368        let tier = tier_for_run_state(to);
369        let time_str = at.format("%H:%M:%S").to_string();
370        let display_id = display_run_id(run_id);
371
372        let mut spans = vec![
373            Span::styled(time_str, Tier::Contextual.style()),
374            Span::styled(" ▸ ", Tier::Background.style()),
375            Span::styled(format!("run/{:<20}", display_id), tier.style()),
376            Span::styled(format!("{:?}", from), Tier::Contextual.style()),
377            Span::styled(" → ", Tier::Background.style()),
378            Span::styled(format!("{:?}", to), tier.style()),
379        ];
380
381        if let Some(r) = reason {
382            spans.push(Span::styled(
383                format!("  (reason: {r})"),
384                Tier::Contextual.style(),
385            ));
386        }
387        spans.push(Span::styled(
388            format!("  progress {}", format_ascii_progress(progress, 20)),
389            Tier::Contextual.style(),
390        ));
391
392        let line = Line::from(spans);
393
394        self.terminal.insert_before(1, |buf| {
395            Paragraph::new(line).render(buf.area, buf);
396        })?;
397
398        Ok(())
399    }
400
401    /// Push a continuation summary block to scrollback.
402    fn push_continuation_summary(
403        &mut self,
404        payload: &crate::yarli_core::entities::ContinuationPayload,
405    ) -> io::Result<()> {
406        let s = &payload.summary;
407
408        // Header line.
409        let header = Line::from(vec![Span::styled(
410            "── Continuation ──",
411            Tier::Active.style(),
412        )]);
413        self.terminal.insert_before(1, |buf| {
414            Paragraph::new(header).render(buf.area, buf);
415        })?;
416
417        // Counts line.
418        let counts = format!(
419            "  {} completed, {} failed, {} pending",
420            s.completed, s.failed, s.pending
421        );
422        let counts_line = Line::from(vec![Span::styled(counts, Tier::Contextual.style())]);
423        self.terminal.insert_before(1, |buf| {
424            Paragraph::new(counts_line).render(buf.area, buf);
425        })?;
426
427        if let Some(reason) = payload.exit_reason {
428            let reason_line = Line::from(vec![Span::styled(
429                format!("  Exit reason: {reason}"),
430                Tier::Contextual.style(),
431            )]);
432            self.terminal.insert_before(1, |buf| {
433                Paragraph::new(reason_line).render(buf.area, buf);
434            })?;
435        }
436
437        let cancelled = payload.exit_state == crate::yarli_core::fsm::run::RunState::RunCancelled;
438        if cancelled || payload.cancellation_source.is_some() {
439            let source = payload
440                .cancellation_source
441                .map(|value| value.to_string())
442                .unwrap_or_else(|| "unknown".to_string());
443            let source_line = Line::from(vec![Span::styled(
444                format!("  Cancel source: {source}"),
445                Tier::Contextual.style(),
446            )]);
447            self.terminal.insert_before(1, |buf| {
448                Paragraph::new(source_line).render(buf.area, buf);
449            })?;
450        }
451
452        if cancelled || payload.cancellation_provenance.is_some() {
453            let summary =
454                format_cancel_provenance_summary(payload.cancellation_provenance.as_ref());
455            let provenance_line = Line::from(vec![Span::styled(
456                format!("  Cancel provenance: {summary}"),
457                Tier::Contextual.style(),
458            )]);
459            self.terminal.insert_before(1, |buf| {
460                Paragraph::new(provenance_line).render(buf.area, buf);
461            })?;
462        }
463
464        // Retry/next lines if there's a tranche.
465        if let Some(tranche) = &payload.next_tranche {
466            if !tranche.retry_task_keys.is_empty() {
467                let retry = format!("  Retry: [{}]", tranche.retry_task_keys.join(", "));
468                let retry_line = Line::from(vec![Span::styled(retry, Tier::Urgent.style())]);
469                self.terminal.insert_before(1, |buf| {
470                    Paragraph::new(retry_line).render(buf.area, buf);
471                })?;
472            }
473            if !tranche.unfinished_task_keys.is_empty() {
474                let unfinished = format!(
475                    "  Unfinished: [{}]",
476                    tranche.unfinished_task_keys.join(", ")
477                );
478                let unfinished_line =
479                    Line::from(vec![Span::styled(unfinished, Tier::Contextual.style())]);
480                self.terminal.insert_before(1, |buf| {
481                    Paragraph::new(unfinished_line).render(buf.area, buf);
482                })?;
483            }
484            let next = format!("  Next: \"{}\"", tranche.suggested_objective);
485            let next_line = Line::from(vec![Span::styled(next, Tier::Active.style())]);
486            self.terminal.insert_before(1, |buf| {
487                Paragraph::new(next_line).render(buf.area, buf);
488            })?;
489        }
490
491        if let Some(quality_gate) = payload.quality_gate.as_ref() {
492            if matches!(
493                quality_gate.task_health_action,
494                TaskHealthAction::ForcePivot
495            ) {
496                if let Some(guidance) = Self::force_pivot_guidance(quality_gate.trend.as_ref()) {
497                    let guidance_line =
498                        Line::from(vec![Span::styled(guidance, Tier::Urgent.style())]);
499                    self.terminal.insert_before(1, |buf| {
500                        Paragraph::new(guidance_line).render(buf.area, buf);
501                    })?;
502                }
503            }
504            if matches!(
505                quality_gate.task_health_action,
506                TaskHealthAction::StopAndSummarize
507            ) {
508                let guidance = format!("  Stop-and-summarize guidance: {}", quality_gate.reason);
509                let guidance_line = Line::from(vec![Span::styled(guidance, Tier::Urgent.style())]);
510                self.terminal.insert_before(1, |buf| {
511                    Paragraph::new(guidance_line).render(buf.area, buf);
512                })?;
513            }
514            if matches!(
515                quality_gate.task_health_action,
516                TaskHealthAction::CheckpointNow
517            ) {
518                let guidance = format!("  Checkpoint-now guidance: {}", quality_gate.reason);
519                let guidance_line = Line::from(vec![Span::styled(guidance, Tier::Urgent.style())]);
520                self.terminal.insert_before(1, |buf| {
521                    Paragraph::new(guidance_line).render(buf.area, buf);
522                })?;
523            }
524        }
525
526        Ok(())
527    }
528
529    fn force_pivot_guidance(trend: Option<&DeteriorationTrend>) -> Option<String> {
530        if matches!(trend, Some(DeteriorationTrend::Deteriorating)) {
531            Some(
532            "  Force-pivot guidance: sequence quality is deteriorating; narrow scope and shift task focus before continuing."
533                .to_string(),
534        )
535        } else {
536            None
537        }
538    }
539
540    /// Redraw the inline viewport with current task status.
541    fn draw_viewport(&mut self) -> io::Result<()> {
542        let tasks: Vec<_> = self
543            .task_order
544            .iter()
545            .filter_map(|id| self.tasks.get(id))
546            .cloned()
547            .collect();
548        let spinners = &self.spinners;
549        let transient = self.transient_status.take();
550        let explain = self.explain_summary.clone();
551
552        self.terminal.draw(|frame| {
553            let area = frame.area();
554            let mut lines = Vec::new();
555
556            // Render each tracked task.
557            for task in &tasks {
558                let (glyph, tier) = match task.state {
559                    TaskState::TaskExecuting => {
560                        let sp = spinners
561                            .get(&task.task_id)
562                            .map(|s| s.frame())
563                            .unwrap_or('⠋');
564                        (sp, Tier::Active)
565                    }
566                    TaskState::TaskWaiting => ('⠿', Tier::Active),
567                    TaskState::TaskBlocked => (GLYPH_BLOCKED, Tier::Contextual),
568                    TaskState::TaskReady => (GLYPH_PENDING, Tier::Contextual),
569                    TaskState::TaskOpen => (GLYPH_PENDING, Tier::Contextual),
570                    TaskState::TaskComplete => (GLYPH_COMPLETE, Tier::Contextual),
571                    TaskState::TaskFailed => (GLYPH_FAILED, Tier::Urgent),
572                    TaskState::TaskCancelled => (GLYPH_BLOCKED, Tier::Contextual),
573                    TaskState::TaskVerifying => (GLYPH_PENDING, Tier::Active),
574                };
575
576                let elapsed_str = task
577                    .elapsed
578                    .map(|d| format!("{}s", d.as_secs()))
579                    .unwrap_or_default();
580
581                let mut spans = vec![
582                    Span::styled("  ", Style::default()),
583                    Span::styled(format!("{glyph} "), tier.style()),
584                    Span::styled(format!("task/{:<14}", task.name), tier.style()),
585                    Span::styled(format!("{:<8}", elapsed_str), Tier::Contextual.style()),
586                ];
587
588                // Show last output line for executing tasks.
589                if let Some(ref output) = task.last_output_line {
590                    let max_len = area.width.saturating_sub(40) as usize;
591                    let truncated = if output.len() > max_len {
592                        // Walk back to the nearest char boundary at or before max_len.
593                        let mut end = max_len;
594                        while end > 0 && !output.is_char_boundary(end) {
595                            end -= 1;
596                        }
597                        &output[..end]
598                    } else {
599                        output.as_str()
600                    };
601                    spans.push(Span::styled(truncated.to_string(), tier.accent()));
602                } else if task.state == TaskState::TaskBlocked {
603                    if let Some(ref by) = task.blocked_by {
604                        spans.push(Span::styled(
605                            format!("blocked-by: {by}"),
606                            Tier::Contextual.accent(),
607                        ));
608                    }
609                } else if task.state == TaskState::TaskReady || task.state == TaskState::TaskOpen {
610                    spans.push(Span::styled("waiting", Tier::Contextual.accent()));
611                }
612
613                lines.push(Line::from(spans));
614            }
615
616            // Transient status (only in viewport).
617            if let Some(msg) = transient {
618                lines.push(Line::from(vec![Span::styled(
619                    format!("  {msg}"),
620                    Tier::Background.style(),
621                )]));
622            }
623
624            // "Why Not Done?" summary at bottom of viewport.
625            if let Some(ref summary) = explain {
626                lines.push(Line::from(vec![
627                    Span::styled("  WHY: ", Tier::Urgent.accent()),
628                    Span::styled(summary.clone(), Tier::Urgent.style()),
629                ]));
630            }
631
632            let paragraph = Paragraph::new(lines);
633            frame.render_widget(paragraph, area);
634        })?;
635
636        Ok(())
637    }
638
639    /// Clean up the inline viewport and reposition the cursor below it.
640    ///
641    /// Call this when the stream event loop is done to leave the terminal
642    /// in a usable state. Also invoked automatically via `Drop`.
643    pub fn restore(&mut self) -> io::Result<()> {
644        // Clear the inline viewport area so leftover spinner lines don't linger.
645        self.terminal.clear()?;
646        io::stdout().flush()?;
647        Ok(())
648    }
649
650    /// Get mutable access to the terminal (for cleanup, etc.).
651    pub fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
652        &mut self.terminal
653    }
654
655    fn push_transient_status_line(
656        &mut self,
657        message: &str,
658        at: DateTime<Utc>,
659        progress: ProgressSnapshot,
660    ) -> io::Result<()> {
661        self.last_transient_status_emit_at = Some(at);
662        let spans = vec![
663            Span::styled(at.format("%H:%M:%S").to_string(), Tier::Contextual.style()),
664            Span::styled(" ▸ ", Tier::Background.style()),
665            Span::styled("status", Tier::Active.style()),
666            Span::styled(
667                format!(
668                    " {message}  progress {}",
669                    format_ascii_progress(progress, 20)
670                ),
671                Tier::Contextual.style(),
672            ),
673        ];
674        let line = Line::from(spans);
675        self.terminal.insert_before(1, |buf| {
676            Paragraph::new(line).render(buf.area, buf);
677        })?;
678        Ok(())
679    }
680
681    fn progress_snapshot(&self) -> ProgressSnapshot {
682        let mut snapshot = ProgressSnapshot {
683            total: self.task_states.len() as u32,
684            ..ProgressSnapshot::default()
685        };
686        for state in self.task_states.values() {
687            match state {
688                TaskState::TaskComplete => snapshot.completed += 1,
689                TaskState::TaskFailed => snapshot.failed += 1,
690                TaskState::TaskCancelled => snapshot.cancelled += 1,
691                _ => {}
692            }
693        }
694        snapshot
695    }
696}
697
698impl Drop for StreamRenderer {
699    fn drop(&mut self) {
700        let _ = self.restore();
701    }
702}
703
704#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
705struct ProgressSnapshot {
706    total: u32,
707    completed: u32,
708    failed: u32,
709    cancelled: u32,
710}
711
712impl ProgressSnapshot {
713    fn terminal_count(self) -> u32 {
714        self.completed + self.failed + self.cancelled
715    }
716}
717
718fn format_ascii_progress(snapshot: ProgressSnapshot, width: usize) -> String {
719    let done = snapshot.terminal_count();
720    let total = snapshot.total;
721    let (filled, percent) = if total == 0 {
722        (0usize, 0u32)
723    } else {
724        let filled = ((done as f64 / total as f64) * width as f64).round() as usize;
725        let percent = ((done as f64 / total as f64) * 100.0).round() as u32;
726        (filled.min(width), percent.min(100))
727    };
728    let bar = format!("{}{}", "#".repeat(filled), ".".repeat(width - filled));
729    format!("[{bar}] {done}/{total} ({percent}%)")
730}
731
732fn should_emit_transient_status_line(
733    last_emit_at: Option<DateTime<Utc>>,
734    message: &str,
735    now: DateTime<Utc>,
736) -> bool {
737    if message.starts_with("operator ") {
738        return true;
739    }
740    let Some(last) = last_emit_at else {
741        return true;
742    };
743    now.signed_duration_since(last).num_seconds() >= TRANSIENT_STATUS_EMIT_SECS
744}
745
746fn format_cancel_provenance_summary(provenance: Option<&CancellationProvenance>) -> String {
747    let signal = provenance
748        .and_then(|p| p.signal_name.as_deref())
749        .unwrap_or("unknown");
750    let sender = provenance
751        .and_then(|p| p.sender_pid)
752        .map(|pid| pid.to_string())
753        .unwrap_or_else(|| "unknown".to_string());
754    let receiver = provenance
755        .and_then(|p| p.receiver_pid)
756        .map(|pid| format!("yarli({pid})"))
757        .unwrap_or_else(|| "unknown".to_string());
758    let actor = provenance
759        .and_then(|p| p.actor_kind)
760        .map(|kind| kind.to_string())
761        .unwrap_or_else(|| "unknown".to_string());
762    let stage = provenance
763        .and_then(|p| p.stage)
764        .map(|stage| stage.to_string())
765        .unwrap_or_else(|| "unknown".to_string());
766    format!("signal={signal} sender={sender} receiver={receiver} actor={actor} stage={stage}")
767}
768
769/// Determine visual tier for a task state.
770fn tier_for_task_state(state: TaskState) -> Tier {
771    match state {
772        TaskState::TaskFailed => Tier::Urgent,
773        TaskState::TaskBlocked => Tier::Contextual,
774        TaskState::TaskExecuting | TaskState::TaskWaiting => Tier::Active,
775        TaskState::TaskComplete => Tier::Contextual,
776        _ => Tier::Contextual,
777    }
778}
779
780fn display_run_id(run_id: uuid::Uuid) -> String {
781    let compact = run_id.simple().to_string();
782    compact[..RUN_ID_DISPLAY_LEN.min(compact.len())].to_string()
783}
784
785/// Determine visual tier for a run state.
786fn tier_for_run_state(state: crate::yarli_core::fsm::run::RunState) -> Tier {
787    use crate::yarli_core::fsm::run::RunState;
788    match state {
789        RunState::RunFailed | RunState::RunBlocked => Tier::Urgent,
790        RunState::RunActive | RunState::RunVerifying => Tier::Active,
791        RunState::RunCompleted => Tier::Contextual,
792        RunState::RunDrained => Tier::Contextual,
793        _ => Tier::Contextual,
794    }
795}
796
797/// Format a Duration as human-readable (e.g. "3.2s", "1m 4s").
798fn format_duration(d: Duration) -> String {
799    let secs = d.as_secs();
800    let millis = d.subsec_millis();
801    if secs >= 60 {
802        format!("{}m {}s", secs / 60, secs % 60)
803    } else if secs >= 10 {
804        format!("{secs}s")
805    } else {
806        format!("{secs}.{millis_h}s", millis_h = millis / 100)
807    }
808}
809
810// Unit tests for pure functions (renderer itself requires a terminal).
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    #[test]
816    fn format_duration_short() {
817        assert_eq!(format_duration(Duration::from_millis(3200)), "3.2s");
818    }
819
820    #[test]
821    fn format_duration_medium() {
822        assert_eq!(format_duration(Duration::from_secs(34)), "34s");
823    }
824
825    #[test]
826    fn format_duration_long() {
827        assert_eq!(format_duration(Duration::from_secs(64)), "1m 4s");
828    }
829
830    #[test]
831    fn format_duration_zero() {
832        assert_eq!(format_duration(Duration::ZERO), "0.0s");
833    }
834
835    #[test]
836    fn tier_for_failed_task() {
837        assert_eq!(tier_for_task_state(TaskState::TaskFailed), Tier::Urgent);
838    }
839
840    #[test]
841    fn tier_for_executing_task() {
842        assert_eq!(tier_for_task_state(TaskState::TaskExecuting), Tier::Active);
843    }
844
845    #[test]
846    fn tier_for_complete_task() {
847        assert_eq!(
848            tier_for_task_state(TaskState::TaskComplete),
849            Tier::Contextual
850        );
851    }
852
853    #[test]
854    fn tier_for_blocked_run() {
855        use crate::yarli_core::fsm::run::RunState;
856        assert_eq!(tier_for_run_state(RunState::RunBlocked), Tier::Urgent);
857    }
858
859    #[test]
860    fn tier_for_active_run() {
861        use crate::yarli_core::fsm::run::RunState;
862        assert_eq!(tier_for_run_state(RunState::RunActive), Tier::Active);
863    }
864
865    #[test]
866    fn tier_for_completed_run() {
867        use crate::yarli_core::fsm::run::RunState;
868        assert_eq!(tier_for_run_state(RunState::RunCompleted), Tier::Contextual);
869    }
870
871    #[test]
872    fn display_run_id_uses_compact_prefix() {
873        let run_id =
874            uuid::Uuid::parse_str("019c4f51-5f70-7d84-a0c8-2f5c6bb8ef12").expect("valid uuid");
875        assert_eq!(display_run_id(run_id), "019c4f515f70");
876    }
877
878    #[test]
879    fn transient_status_line_throttles_regular_heartbeats() {
880        let now = Utc::now();
881        assert!(!should_emit_transient_status_line(
882            Some(now),
883            "heartbeat: pending=1 leased=1",
884            now + chrono::Duration::seconds(10),
885        ));
886        assert!(should_emit_transient_status_line(
887            Some(now),
888            "heartbeat: pending=1 leased=1",
889            now + chrono::Duration::seconds(31),
890        ));
891    }
892
893    #[test]
894    fn transient_status_line_always_emits_operator_messages() {
895        let now = Utc::now();
896        assert!(should_emit_transient_status_line(
897            Some(now),
898            "operator pause: maintenance",
899            now + chrono::Duration::seconds(1),
900        ));
901    }
902
903    #[test]
904    fn ascii_progress_formats_empty_snapshot() {
905        let snapshot = ProgressSnapshot::default();
906        assert_eq!(format_ascii_progress(snapshot, 10), "[..........] 0/0 (0%)");
907    }
908
909    #[test]
910    fn ascii_progress_formats_partial_completion() {
911        let snapshot = ProgressSnapshot {
912            total: 5,
913            completed: 2,
914            failed: 0,
915            cancelled: 0,
916        };
917        assert_eq!(
918            format_ascii_progress(snapshot, 10),
919            "[####......] 2/5 (40%)"
920        );
921    }
922
923    #[test]
924    fn ascii_progress_counts_all_terminal_states() {
925        let snapshot = ProgressSnapshot {
926            total: 4,
927            completed: 1,
928            failed: 1,
929            cancelled: 1,
930        };
931        assert_eq!(
932            format_ascii_progress(snapshot, 10),
933            "[########..] 3/4 (75%)"
934        );
935    }
936}