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