Skip to main content

mermaid_cli/render/widgets/
status.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::Style,
5    text::{Line, Span},
6    widgets::{Paragraph, Widget},
7};
8use unicode_width::UnicodeWidthStr;
9
10use super::truncate_to_cells;
11use crate::render::theme::Theme;
12use mermaid_domain::ContextUsageSnapshot;
13use mermaid_model::models::ReasoningLevel;
14use mermaid_model::safety::SafetyMode;
15
16/// The one-line footer under the composer: the session's mode on the left,
17/// the model and the context gauge on the right, everything in the theme's
18/// meta colour. What it deliberately does not show: `user@host` (the shell
19/// prompt and the window title already do), the working directory (the
20/// session header names it once), and the version (`mermaid --version`,
21/// `/doctor`). A footer is furniture; it should carry what changes.
22pub struct StatusWidget<'a> {
23    pub theme: &'a Theme,
24    pub context_usage: Option<&'a ContextUsageSnapshot>,
25    pub model_name: &'a str,
26    /// Effective reasoning depth — what the API actually saw after
27    /// `nearest_effort` snapping against the model's capabilities.
28    pub reasoning_level: ReasoningLevel,
29    /// User-requested level when it differs from `reasoning_level` (the snap
30    /// case). `Some(requested)` shows `reasoning: high (max requested)`.
31    pub requested_level: Option<ReasoningLevel>,
32    /// Live session safety mode — including `plan`, which is a mode like any
33    /// other and renders as plain `safety: plan`. Never the spinner/status
34    /// widget (#245 invariant) — this is the persistent mode line.
35    pub safety_mode: SafetyMode,
36}
37
38impl<'a> Widget for StatusWidget<'a> {
39    fn render(self, area: Rect, buf: &mut Buffer) {
40        let width = area.width as usize;
41        let left = footer_left(self.safety_mode, self.reasoning_level, self.requested_level);
42        let right = footer_right(
43            self.model_name,
44            context_segment(self.context_usage).as_deref(),
45            width.saturating_sub(left.width() + 1),
46        );
47        let gap = width.saturating_sub(left.width() + right.width()).max(1);
48        let meta = Style::new().fg(self.theme.colors.text_meta.to_color());
49        let line = Line::from(vec![
50            Span::styled(left, meta),
51            Span::raw(" ".repeat(gap)),
52            Span::styled(right, meta),
53        ]);
54        Paragraph::new(vec![line]).render(area, buf);
55    }
56}
57
58/// `safety: <mode> · reasoning: <level>[ (<requested> requested)]`.
59pub(crate) fn footer_left(
60    safety_mode: SafetyMode,
61    reasoning_level: ReasoningLevel,
62    requested_level: Option<ReasoningLevel>,
63) -> String {
64    let reasoning = match requested_level {
65        Some(requested) => format!(
66            "reasoning: {} ({} requested)",
67            reasoning_level.as_str(),
68            requested.as_str()
69        ),
70        None => format!("reasoning: {}", reasoning_level.as_str()),
71    };
72    format!("safety: {} · {reasoning}", safety_mode.as_str())
73}
74
75/// The context gauge, or nothing until the provider has reported usage:
76/// `context: n/a` at every session start was a null value dressed as
77/// information.
78pub(crate) fn context_segment(context_usage: Option<&ContextUsageSnapshot>) -> Option<String> {
79    let snapshot = context_usage?;
80    let percent = snapshot.used_percent?;
81    Some(format!("context {percent}%"))
82}
83
84/// `<model>[ · context N%]`, fitted into `budget` cells: when the full id does
85/// not fit, the provider prefix goes first (the header names it, and the
86/// vendor namespace is the part that distinguishes models), then the text is
87/// truncated.
88pub(crate) fn footer_right(model: &str, context: Option<&str>, budget: usize) -> String {
89    let assemble = |model: &str| match context {
90        Some(ctx) => format!("{model} · {ctx}"),
91        None => model.to_string(),
92    };
93    let full = assemble(model);
94    if full.width() <= budget {
95        return full;
96    }
97    let short = model.split_once('/').map_or(model, |(_, rest)| rest);
98    let shorter = assemble(short);
99    if shorter.width() <= budget {
100        return shorter;
101    }
102    truncate_to_cells(&shorter, budget)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn footer_left_shows_safety_and_reasoning() {
111        let s = footer_left(SafetyMode::Ask, ReasoningLevel::High, None);
112        assert_eq!(s, "safety: ask · reasoning: high");
113        let s = footer_left(
114            SafetyMode::Ask,
115            ReasoningLevel::High,
116            Some(ReasoningLevel::Max),
117        );
118        assert_eq!(s, "safety: ask · reasoning: high (max requested)");
119    }
120
121    #[test]
122    fn footer_left_renders_plan_as_a_plain_safety_mode() {
123        let s = footer_left(SafetyMode::Plan, ReasoningLevel::Medium, None);
124        assert!(s.starts_with("safety: plan"), "{s}");
125        assert!(!s.contains("restores"));
126    }
127
128    #[test]
129    fn context_segment_is_absent_until_usage_is_known() {
130        assert_eq!(context_segment(None), None);
131        let estimate = ContextUsageSnapshot::from_estimate(
132            mermaid_domain::PromptTokenBreakdown {
133                system_tokens: 10,
134                instructions_tokens: 0,
135                message_tokens: 20,
136                tool_schema_tokens: 70,
137                image_count: 0,
138                message_count: 1,
139                tool_count: 4,
140            },
141            None,
142        );
143        assert_eq!(
144            context_segment(Some(&estimate)),
145            None,
146            "no window, no percent"
147        );
148        let known = ContextUsageSnapshot::from_usage(
149            &mermaid_model::models::TokenUsage::provider(12_000, 456),
150            Some(128_000),
151        );
152        assert_eq!(context_segment(Some(&known)).as_deref(), Some("context 9%"));
153    }
154
155    #[test]
156    fn footer_right_drops_the_provider_before_it_truncates() {
157        let full = footer_right("anthropic/claude-sonnet-4-5", Some("context 100%"), 80);
158        assert_eq!(full, "anthropic/claude-sonnet-4-5 · context 100%");
159        let squeezed = footer_right("anthropic/claude-sonnet-4-5", Some("context 100%"), 34);
160        assert_eq!(squeezed, "claude-sonnet-4-5 · context 100%");
161        let cut = footer_right("anthropic/claude-sonnet-4-5", Some("context 100%"), 12);
162        assert!(cut.width() <= 12, "{cut}");
163    }
164}