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