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. Never the spinner/status widget (#245
33    /// invariant) — this is the persistent mode line.
34    pub safety_mode: SafetyMode,
35}
36
37impl<'a> Widget for StatusWidget<'a> {
38    fn render(self, area: Rect, buf: &mut Buffer) {
39        let width = area.width as usize;
40        let left = footer_left(self.safety_mode, self.reasoning_level, self.requested_level);
41        let right = footer_right(
42            self.model_name,
43            context_segment(self.context_usage).as_deref(),
44            width.saturating_sub(left.width() + 1),
45        );
46        let gap = width.saturating_sub(left.width() + right.width()).max(1);
47        let meta = Style::new().fg(self.theme.colors.text_meta.to_color());
48        let line = Line::from(vec![
49            Span::styled(left, meta),
50            Span::raw(" ".repeat(gap)),
51            Span::styled(right, meta),
52        ]);
53        Paragraph::new(vec![line]).render(area, buf);
54    }
55}
56
57/// `safety: <mode> · reasoning: <level>[ (<requested> requested)]`.
58pub(crate) fn footer_left(
59    safety_mode: SafetyMode,
60    reasoning_level: ReasoningLevel,
61    requested_level: Option<ReasoningLevel>,
62) -> String {
63    let reasoning = match requested_level {
64        Some(requested) => format!(
65            "reasoning: {} ({} requested)",
66            reasoning_level.as_str(),
67            requested.as_str()
68        ),
69        None => format!("reasoning: {}", reasoning_level.as_str()),
70    };
71    format!("safety: {} · {reasoning}", safety_mode.as_str())
72}
73
74/// The context gauge, or nothing until the provider has reported usage:
75/// `context: n/a` at every session start was a null value dressed as
76/// information.
77pub(crate) fn context_segment(context_usage: Option<&ContextUsageSnapshot>) -> Option<String> {
78    let snapshot = context_usage?;
79    let percent = snapshot.used_percent?;
80    Some(format!("context {percent}%"))
81}
82
83/// `<model>[ · context N%]`, fitted into `budget` cells: when the full id does
84/// not fit, the provider prefix goes first (the header names it, and the
85/// vendor namespace is the part that distinguishes models), then the text is
86/// truncated.
87pub(crate) fn footer_right(model: &str, context: Option<&str>, budget: usize) -> String {
88    let assemble = |model: &str| match context {
89        Some(ctx) => format!("{model} · {ctx}"),
90        None => model.to_string(),
91    };
92    let full = assemble(model);
93    if full.width() <= budget {
94        return full;
95    }
96    let short = model.split_once('/').map_or(model, |(_, rest)| rest);
97    let shorter = assemble(short);
98    if shorter.width() <= budget {
99        return shorter;
100    }
101    truncate_to_cells(&shorter, budget)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn footer_left_shows_safety_and_reasoning() {
110        let s = footer_left(SafetyMode::Ask, ReasoningLevel::High, None);
111        assert_eq!(s, "safety: ask · reasoning: high");
112        let s = footer_left(
113            SafetyMode::Ask,
114            ReasoningLevel::High,
115            Some(ReasoningLevel::Max),
116        );
117        assert_eq!(s, "safety: ask · reasoning: high (max requested)");
118    }
119
120    #[test]
121    fn context_segment_is_absent_until_usage_is_known() {
122        assert_eq!(context_segment(None), None);
123        let estimate = ContextUsageSnapshot::from_estimate(
124            mermaid_domain::PromptTokenBreakdown {
125                system_tokens: 10,
126                instructions_tokens: 0,
127                message_tokens: 20,
128                tool_schema_tokens: 70,
129                image_count: 0,
130                message_count: 1,
131                tool_count: 4,
132            },
133            None,
134        );
135        assert_eq!(
136            context_segment(Some(&estimate)),
137            None,
138            "no window, no percent"
139        );
140        let known = ContextUsageSnapshot::from_usage(
141            &mermaid_model::models::TokenUsage::provider(12_000, 456),
142            Some(128_000),
143        );
144        assert_eq!(context_segment(Some(&known)).as_deref(), Some("context 9%"));
145    }
146
147    #[test]
148    fn footer_right_drops_the_provider_before_it_truncates() {
149        let full = footer_right("anthropic/claude-sonnet-4-5", Some("context 100%"), 80);
150        assert_eq!(full, "anthropic/claude-sonnet-4-5 · context 100%");
151        let squeezed = footer_right("anthropic/claude-sonnet-4-5", Some("context 100%"), 34);
152        assert_eq!(squeezed, "claude-sonnet-4-5 · context 100%");
153        let cut = footer_right("anthropic/claude-sonnet-4-5", Some("context 100%"), 12);
154        assert!(cut.width() <= 12, "{cut}");
155    }
156}