Skip to main content

mermaid_cli/render/widgets/
status_line.rs

1use ratatui::style::Style;
2use ratatui::text::{Line, Span};
3use std::collections::VecDeque;
4use unicode_width::UnicodeWidthStr;
5
6use super::{GenerationStatus, truncate_line_to_cells, truncate_to_cells};
7use crate::render::markdown::parse_markdown_inline;
8use crate::render::theme::Theme;
9use mermaid_domain::QueuedMessage;
10
11/// How many queued-message rows to show under the spinner before stopping.
12const MAX_QUEUED_ROWS: usize = 5;
13
14/// How many live agent rows to show under the spinner before eliding.
15const MAX_AGENT_ROWS: usize = 6;
16
17/// One row of the live agent panel: a running (or backgrounded) subagent's
18/// stable identity + activity. Built by the render layer from
19/// `TurnState::ExecutingTools` + `live_tool_status` (+ the background-agent
20/// registry); this widget only formats.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct AgentPanelRow {
23    pub description: String,
24    /// Short stable label ("`read_file`…", "thinking"); empty = omitted.
25    pub activity: String,
26    /// Cumulative output-token estimate; 0 = omitted.
27    pub tokens: usize,
28    pub elapsed_secs: u64,
29    /// True for agents detached via Ctrl+B (still running, turn released).
30    pub backgrounded: bool,
31}
32
33/// The spinner's frames: a half disc walking clockwise. Geometric shapes,
34/// not dingbats (the no-pictograph rule); East-Asian-Width Ambiguous like the
35/// `●` already on screen, so every frame is one cell wide and the row never
36/// breathes.
37pub(crate) const SPINNER_FRAMES: [&str; 4] = ["◐ ", "◓ ", "◑ ", "◒ "];
38const SPINNER_FRAME_MS: u128 = 150;
39
40/// The frame for a given elapsed time. A pure function of the injected
41/// clock, so `--replay` reproduces every frame.
42#[must_use]
43pub fn spinner_glyph(elapsed: std::time::Duration) -> &'static str {
44    let index = (elapsed.as_millis() / SPINNER_FRAME_MS) % SPINNER_FRAMES.len() as u128;
45    SPINNER_FRAMES[index as usize]
46}
47
48/// Build the status-line rows: a generation/tool spinner followed by one row
49/// per queued message.
50///
51/// When the spinner fits in `width` it stays on one row. When it doesn't (a
52/// long task headline), it splits into exactly two rows — the status text on
53/// row 1, the `(esc to interrupt …)` metadata on row 2 (indented under the
54/// status text) — and each row is *truncated* to `width` so nothing ever
55/// bleeds off the right edge. The fixed 1-or-2-row shape keeps the reserved
56/// height stable as the timer/token counter tick (no per-frame reflow).
57#[expect(clippy::too_many_arguments)]
58#[must_use]
59pub fn build_status_lines(
60    status: GenerationStatus,
61    elapsed: std::time::Duration,
62    tokens_received: Option<usize>,
63    status_override: Option<&str>,
64    agents: &[AgentPanelRow],
65    bg_available: bool,
66    task_headline: Option<&str>,
67    queued_messages: &VecDeque<QueuedMessage>,
68    exit_armed: bool,
69    theme: &Theme,
70    width: u16,
71) -> Vec<Line<'static>> {
72    // Idle renders nothing — except when detached background agents are still
73    // running, whose rows must stay visible between turns.
74    if (status == GenerationStatus::Idle && agents.is_empty()) || width < 10 {
75        return Vec::new();
76    }
77    let width = width as usize;
78
79    // The headline, by precedence:
80    //   1. An in_progress checklist task's `active_form` (Claude Code parity —
81    //      the spinner row reads as the checklist header).
82    //   2. An override replacing the whole text ("Running 3 agents") when the
83    //      agent panel carries the detail.
84    //   3. The bare generation phase. Never the tool name or its arguments —
85    //      per-tool detail belongs to the transcript's action rows, not here.
86    let status_text = match (task_headline, status_override) {
87        (Some(head), _) => head.to_string(),
88        (None, Some(text)) => text.to_string(),
89        (None, None) => status.display_text().to_string(),
90    };
91
92    let info_style = Style::new().fg(theme.colors.info.to_color());
93    // The theme's one meta colour, undimmed: SGR faint over an RGB colour is
94    // terminal-dependent, so "dim secondary" and "meta gray" read as two greys
95    // on some terminals and one on others.
96    let meta_style = Style::new().fg(theme.colors.text_meta.to_color());
97
98    // One animated head glyph for every busy phase, from the injected clock.
99    // (The old `↑`/`↓`/`•` heads named the flow direction, and the streaming
100    // row then carried the same arrow twice.)
101    let arrow = if status == GenerationStatus::Idle {
102        ""
103    } else {
104        spinner_glyph(elapsed)
105    };
106
107    // While detachable tools run (shell commands, agents), advertise Ctrl+B.
108    // Hidden when nothing running can actually background — the hint used to
109    // render unconditionally and lie during read/edit-only turns.
110    let bg_hint = if status == GenerationStatus::RunningTools && bg_available {
111        " · ctrl+b to background"
112    } else {
113        ""
114    };
115
116    // A first Ctrl+C armed the exit confirmation: lead the meta with the
117    // second-press hint while the window is open.
118    let exit_hint = if exit_armed {
119        "ctrl+c again to exit · "
120    } else {
121        ""
122    };
123
124    let head_text = format!("{status_text}...");
125    let parsed_head = parse_markdown_inline(&head_text, theme, info_style);
126    let head_w: usize = parsed_head.spans.iter().map(|s| s.content.width()).sum();
127
128    // The counter is generated (received) tokens, so it reads downstream even
129    // while tools run — the run total just holds steady between model calls.
130    // Absent while compacting or cancelling, where there is no live count.
131    let tokens = tokens_received.map_or_else(String::new, |n| {
132        format!(
133            " · ↓ {} tokens",
134            mermaid_domain::compaction::format_compact_count(n)
135        )
136    });
137    let meta = format!(
138        "({exit_hint}esc to interrupt{bg_hint} · {}s{tokens})",
139        elapsed.as_secs()
140    );
141
142    let arrow_w = arrow.width();
143    let single_w = arrow_w + head_w + 1 + meta.width();
144
145    let mut lines: Vec<Line<'static>> = Vec::new();
146    if status == GenerationStatus::Idle {
147        // Idle-with-background-agents: rows only, no spinner head.
148    } else if single_w <= width {
149        // Fits on one row — keep it compact (the common case).
150        let mut row_spans = vec![Span::styled(arrow, info_style)];
151        row_spans.extend(parsed_head.spans);
152        row_spans.push(Span::styled(" ", info_style));
153        row_spans.push(Span::styled(meta, meta_style));
154        lines.push(Line::from(row_spans));
155    } else {
156        // Too wide: status text on row 1, metadata on row 2 (indented under the
157        // text). Truncate each so a long command or path can't bleed off-screen.
158        let head_budget = width.saturating_sub(arrow_w);
159        let truncated_head = truncate_line_to_cells(parsed_head, head_budget);
160        let mut head_row_spans = vec![Span::styled(arrow, info_style)];
161        head_row_spans.extend(truncated_head.spans);
162        lines.push(Line::from(head_row_spans));
163        lines.push(Line::from(vec![
164            Span::raw("  "),
165            Span::styled(
166                truncate_to_cells(&meta, width.saturating_sub(2)),
167                meta_style,
168            ),
169        ]));
170    }
171
172    push_agent_rows(&mut lines, agents, theme, width);
173
174    // Queued messages: one truncated, highlighted row each (bounded count).
175    let body_budget = width.saturating_sub(2); // "> " prefix
176    for queued in queued_messages.iter().take(MAX_QUEUED_ROWS) {
177        lines.push(Line::from(vec![Span::styled(
178            format!("> {}", truncate_to_cells(&queued.text, body_budget)),
179            Style::new()
180                .fg(theme.colors.text_primary.to_color())
181                .bg(theme.colors.queued_bg.to_color()),
182        )]));
183    }
184
185    lines
186}
187
188/// Live agent rows: one stable row per running/backgrounded subagent.
189/// Description leads in the accent color; activity/elapsed/tokens trail in
190/// the muted meta style. Counters tick but the row COUNT stays stable, so
191/// the transcript never reflows mid-run.
192fn push_agent_rows(
193    lines: &mut Vec<Line<'static>>,
194    agents: &[AgentPanelRow],
195    theme: &Theme,
196    width: usize,
197) {
198    let meta_style = Style::new().fg(theme.colors.text_meta.to_color());
199    for row in agents.iter().take(MAX_AGENT_ROWS) {
200        let marker = if row.backgrounded { "◦ bg " } else { "◦ " };
201        let desc = format!("  {marker}{}", row.description);
202        let mut bits: Vec<String> = Vec::new();
203        if !row.activity.is_empty() {
204            bits.push(row.activity.clone());
205        }
206        bits.push(format!("{}s", row.elapsed_secs));
207        if row.tokens > 0 {
208            bits.push(format!(
209                "↓ {} tokens",
210                mermaid_domain::compaction::format_compact_count(row.tokens)
211            ));
212        }
213        let desc_budget = width.min(desc.width());
214        let meta_budget = width.saturating_sub(desc_budget + 2);
215        let mut spans = vec![Span::styled(
216            truncate_to_cells(&desc, width),
217            Style::new().fg(theme.colors.info.to_color()),
218        )];
219        if meta_budget > 3 {
220            spans.push(Span::styled(
221                format!("  {}", truncate_to_cells(&bits.join(" · "), meta_budget)),
222                meta_style,
223            ));
224        }
225        lines.push(Line::from(spans));
226    }
227    if agents.len() > MAX_AGENT_ROWS {
228        lines.push(Line::from(vec![Span::styled(
229            format!("  … +{} more", agents.len() - MAX_AGENT_ROWS),
230            meta_style,
231        )]));
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::render::theme::Theme;
239
240    fn row_width(line: &Line<'_>) -> usize {
241        line.spans.iter().map(|s| s.content.as_ref().width()).sum()
242    }
243
244    #[test]
245    fn long_task_headline_splits_and_fits_width() {
246        let theme = Theme::dark();
247        let queued = VecDeque::new();
248        let lines = build_status_lines(
249            GenerationStatus::RunningTools,
250            std::time::Duration::from_secs(3),
251            Some(0),
252            None,
253            &[],
254            true,
255            Some("Rewiring the provider factory so runtime toggles ride on ChatRequest end to end"),
256            &queued,
257            false,
258            &theme,
259            80,
260        );
261        // Splits into status row + metadata row, neither exceeding the width.
262        assert_eq!(lines.len(), 2, "a too-wide status splits onto two rows");
263        for l in &lines {
264            assert!(
265                row_width(l) <= 80,
266                "row exceeds width: {} > 80",
267                row_width(l)
268            );
269        }
270    }
271
272    #[test]
273    fn running_tools_headline_is_the_bare_phase_word() {
274        // Regression: the in-flight tool (name + command/path) used to be
275        // folded into the spinner headline ("Running tools: Bash pwd; …").
276        // Per-tool detail belongs to the transcript; the status line must
277        // never carry it.
278        let theme = Theme::dark();
279        let queued = VecDeque::new();
280        let lines = build_status_lines(
281            GenerationStatus::RunningTools,
282            std::time::Duration::from_secs(11),
283            Some(169),
284            None,
285            &[],
286            true,
287            None,
288            &queued,
289            false,
290            &theme,
291            120,
292        );
293        let text: String = lines
294            .iter()
295            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
296            .collect();
297        assert!(
298            text.contains("Running tools..."),
299            "bare phase word expected: {text}"
300        );
301        assert!(
302            !text.contains(':'),
303            "no tool detail may follow the phase word: {text}"
304        );
305    }
306
307    #[test]
308    fn thinking_shows_downstream_arrow_and_live_token_count() {
309        // Regression: the live counter sat at 0 through the (often long) thinking
310        // phase. It must climb and read as received (downstream) tokens.
311        let theme = Theme::dark();
312        let queued = VecDeque::new();
313        let lines = build_status_lines(
314            GenerationStatus::Thinking,
315            std::time::Duration::from_secs(7),
316            Some(1_234),
317            None,
318            &[],
319            true,
320            None,
321            &queued,
322            false,
323            &theme,
324            120,
325        );
326        let text: String = lines
327            .iter()
328            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
329            .collect();
330        assert!(
331            text.contains("1.2k"),
332            "must show the live count, compact: {text}"
333        );
334        assert!(
335            text.contains('↓'),
336            "thinking receives tokens (downstream): {text}"
337        );
338        assert!(!text.contains('↑'), "thinking is not upstream: {text}");
339    }
340
341    #[test]
342    fn unbreakable_long_headline_is_truncated_not_overflowed() {
343        // Regression (review finding): a long whitespace-free headline used to
344        // be pushed whole onto a row and still bleed off the right edge.
345        let theme = Theme::dark();
346        let queued = VecDeque::new();
347        let lines = build_status_lines(
348            GenerationStatus::RunningTools,
349            std::time::Duration::from_secs(1),
350            Some(0),
351            None,
352            &[],
353            true,
354            Some("Editing D:/Code/AI/some/very/deeply/nested/directory/structure/longfilename.rs"),
355            &queued,
356            false,
357            &theme,
358            40,
359        );
360        for l in &lines {
361            assert!(
362                row_width(l) <= 40,
363                "no row may exceed width even for an unbreakable path: {} > 40",
364                row_width(l)
365            );
366        }
367    }
368
369    #[test]
370    fn short_status_stays_one_row() {
371        let theme = Theme::dark();
372        let queued = VecDeque::new();
373        let lines = build_status_lines(
374            GenerationStatus::Sending,
375            std::time::Duration::from_secs(0),
376            Some(0),
377            None,
378            &[],
379            true,
380            None,
381            &queued,
382            false,
383            &theme,
384            120,
385        );
386        assert_eq!(lines.len(), 1, "a short status stays on one row");
387    }
388
389    #[test]
390    fn height_is_stable_as_metadata_ticks() {
391        // The 1-or-2-row shape must not flip as elapsed/token counters grow, so
392        // the chat transcript doesn't reflow every second.
393        let theme = Theme::dark();
394        let queued = VecDeque::new();
395        let headline = Some("Running the full local gate across every workspace crate and target");
396        let n0 = build_status_lines(
397            GenerationStatus::RunningTools,
398            std::time::Duration::from_secs(9),
399            Some(99),
400            None,
401            &[],
402            true,
403            headline,
404            &queued,
405            false,
406            &theme,
407            100,
408        )
409        .len();
410        for (elapsed, tokens) in [(10, 100), (999, 100000), (3600, 999999)] {
411            let n = build_status_lines(
412                GenerationStatus::RunningTools,
413                std::time::Duration::from_secs(elapsed),
414                Some(tokens),
415                None,
416                &[],
417                true,
418                headline,
419                &queued,
420                false,
421                &theme,
422                100,
423            )
424            .len();
425            assert_eq!(n, n0, "row count must not change as counters tick");
426        }
427    }
428
429    #[test]
430    fn armed_exit_shows_second_press_hint() {
431        let theme = Theme::dark();
432        let queued = VecDeque::new();
433        let lines = build_status_lines(
434            GenerationStatus::Streaming,
435            std::time::Duration::from_secs(2),
436            Some(10),
437            None,
438            &[],
439            true,
440            None,
441            &queued,
442            true,
443            &theme,
444            120,
445        );
446        let text: String = lines
447            .iter()
448            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
449            .collect();
450        assert!(
451            text.contains("ctrl+c again to exit"),
452            "armed exit must surface the second-press hint: {text}"
453        );
454    }
455
456    #[test]
457    fn idle_status_is_empty() {
458        let theme = Theme::dark();
459        let queued = VecDeque::new();
460        let lines = build_status_lines(
461            GenerationStatus::Idle,
462            std::time::Duration::from_secs(0),
463            None,
464            None,
465            &[],
466            true,
467            None,
468            &queued,
469            false,
470            &theme,
471            80,
472        );
473        assert!(lines.is_empty(), "idle has no status row");
474    }
475}