Skip to main content

mermaid_cli/render/widgets/
status_banner.rs

1//! One-line banner for transient `state.status` messages.
2//!
3//! The reducer sets `state.status` for slash-command feedback
4//! (`/model`, `/reasoning`, unknown command), MCP server errors, model
5//! pull progress, and the `/help` hint. Before F9 none of it was
6//! rendered — the render zone for status was only allocated while
7//! `is_busy()`, and even then it showed the generation-phase line, not
8//! `state.status`.
9//!
10//! This widget paints one line, color-keyed by `StatusKind`. Rendered
11//! in `render::mod` whenever `state.status.is_some()`. Auto-dismiss
12//! still flows through the existing `Cmd::DismissStatusAfter` →
13//! `Msg::StatusDismiss` path.
14
15use ratatui::buffer::Buffer;
16use ratatui::layout::Rect;
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Paragraph, Widget};
20
21use crate::domain::{StatusKind, StatusLine};
22use crate::render::theme::Theme;
23
24pub struct StatusBannerWidget<'a> {
25    pub theme: &'a Theme,
26    pub status: &'a StatusLine,
27}
28
29impl<'a> Widget for StatusBannerWidget<'a> {
30    fn render(self, area: Rect, buf: &mut Buffer) {
31        if area.height == 0 || area.width == 0 {
32            return;
33        }
34
35        // Color by severity. Persistent uses the text-disabled gray so
36        // it reads as ambient rather than alarm.
37        let fg = match self.status.kind {
38            StatusKind::Info => self.theme.colors.info.to_color(),
39            StatusKind::Warn => self.theme.colors.warning.to_color(),
40            StatusKind::Error => self.theme.colors.error.to_color(),
41            StatusKind::Persistent => self.theme.colors.text_disabled.to_color(),
42        };
43
44        // Leading glyph mirrors `chat::render_actions` style — a bullet
45        // sized to match and colored by severity.
46        let glyph = match self.status.kind {
47            StatusKind::Error => "✗ ",
48            StatusKind::Warn => "! ",
49            _ => "● ",
50        };
51
52        // Truncate on char boundary so CJK / emoji don't panic. One row
53        // leaves 2 cells of glyph + 1 space of breathing room after,
54        // so we cap the text at `width - 3` cells. Byte-count
55        // truncation is close-enough for a status banner; exact cell
56        // width would require unicode_width, which this widget doesn't
57        // need to pull in.
58        let max_body = area.width.saturating_sub(3) as usize;
59        let text = &self.status.text;
60        let truncated = if text.len() > max_body {
61            let cut = text.floor_char_boundary(max_body.saturating_sub(1));
62            format!("{}…", &text[..cut])
63        } else {
64            text.to_string()
65        };
66
67        let line = Line::from(vec![
68            Span::styled(glyph, Style::new().fg(fg).add_modifier(Modifier::BOLD)),
69            Span::styled(truncated, Style::new().fg(Color::White)),
70        ]);
71        Paragraph::new(line).render(area, buf);
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::render::theme::Theme;
79    use ratatui::Terminal;
80    use ratatui::backend::TestBackend;
81
82    fn render_to_string(banner: StatusBannerWidget<'_>, width: u16) -> String {
83        let backend = TestBackend::new(width, 1);
84        let mut terminal = Terminal::new(backend).expect("terminal");
85        terminal
86            .draw(|f| {
87                let area = Rect::new(0, 0, width, 1);
88                banner.render(area, f.buffer_mut());
89            })
90            .expect("draw");
91        let buf = terminal.backend().buffer();
92        let mut out = String::new();
93        for x in 0..buf.area.width {
94            out.push_str(buf[(x, 0)].symbol());
95        }
96        out
97    }
98
99    #[test]
100    fn info_status_renders_bullet_and_text() {
101        let theme = Theme::dark();
102        let status = StatusLine {
103            text: "Current model: ollama/qwen3-coder:30b".to_string(),
104            kind: StatusKind::Info,
105            shown_at: std::time::SystemTime::now(),
106        };
107        let banner = StatusBannerWidget {
108            theme: &theme,
109            status: &status,
110        };
111        let rendered = render_to_string(banner, 80);
112        assert!(rendered.contains("●"));
113        assert!(rendered.contains("ollama/qwen3-coder:30b"));
114    }
115
116    #[test]
117    fn error_status_uses_error_glyph() {
118        let theme = Theme::dark();
119        let status = StatusLine {
120            text: "MCP server foo errored: exit 1".to_string(),
121            kind: StatusKind::Error,
122            shown_at: std::time::SystemTime::now(),
123        };
124        let banner = StatusBannerWidget {
125            theme: &theme,
126            status: &status,
127        };
128        let rendered = render_to_string(banner, 80);
129        assert!(rendered.contains("✗"));
130        assert!(rendered.contains("exit 1"));
131    }
132
133    /// Long messages truncate on a char boundary. CJK content must not
134    /// panic — the underlying `floor_char_boundary` handles the byte
135    /// alignment; this test pins the behavior.
136    #[test]
137    fn long_cjk_text_truncates_without_panic() {
138        let theme = Theme::dark();
139        // 30 CJK chars = 90 bytes, each 2 display cells = 60 cells.
140        let text: String = "你好世界".repeat(30);
141        let status = StatusLine {
142            text,
143            kind: StatusKind::Info,
144            shown_at: std::time::SystemTime::now(),
145        };
146        let banner = StatusBannerWidget {
147            theme: &theme,
148            status: &status,
149        };
150        // Pretend a narrow terminal — must not panic and must produce
151        // *something*.
152        let _ = render_to_string(banner, 10);
153    }
154}