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 crate::domain::{ContextUsageSnapshot, TokenUsageTotals};
11use crate::models::{ReasoningLevel, TokenUsageSource};
12use crate::render::theme::Theme;
13use crate::runtime::SafetyMode;
14
15/// Props for StatusWidget (stateless widget)
16pub struct StatusWidget<'a> {
17    pub theme: &'a Theme,
18    pub working_dir: &'a str,
19    pub context_usage: Option<&'a ContextUsageSnapshot>,
20    pub last_usage: Option<TokenUsageTotals>,
21    pub session_usage: TokenUsageTotals,
22    pub model_name: &'a str,
23    /// Effective reasoning depth — what the API actually saw after
24    /// `nearest_effort` snapping against the model's capabilities. Always
25    /// rendered on line 2 left.
26    pub reasoning_level: ReasoningLevel,
27    /// User-requested level when it differs from `reasoning_level` (the
28    /// snap case). `Some(requested)` shows `reasoning: high (max
29    /// requested)`; `None` shows just `reasoning: high`.
30    pub requested_level: Option<ReasoningLevel>,
31    /// Live session safety mode. Rendered on line 2 left so the active
32    /// permission level is always visible (Shift+Tab / `/safety` change it).
33    pub safety_mode: SafetyMode,
34}
35
36impl<'a> Widget for StatusWidget<'a> {
37    fn render(self, area: Rect, buf: &mut Buffer) {
38        // Get hostname and username for directory display
39        let hostname = std::env::var("HOSTNAME")
40            .or_else(|_| std::env::var("HOST"))
41            .unwrap_or_else(|_| "localhost".to_string());
42        let username = std::env::var("USER")
43            .or_else(|_| std::env::var("USERNAME"))
44            .unwrap_or_else(|_| "user".to_string());
45
46        // Line 1: username@hostname:/path (left) | token usage (right, fixed position)
47        let directory_text = format!("{}@{}:{}", username, hostname, self.working_dir);
48        let token_text =
49            format_token_status(self.context_usage, self.last_usage, self.session_usage);
50
51        // Calculate padding to push tokens to right edge. Use display-cell
52        // widths so CJK / emoji chars in working_dir or hostname don't
53        // misalign the right-anchored token count.
54        let available_width = area.width as usize;
55        let directory_width = directory_text.width();
56        let token_width = token_text.width();
57        let padding_width = if available_width > directory_width + token_width + 1 {
58            available_width - directory_width - token_width
59        } else {
60            1
61        };
62
63        let line1_spans = vec![
64            // Directory (fixed to left)
65            Span::styled(
66                format!("{}@{}", username, hostname),
67                Style::new().fg(ratatui::style::Color::Green).bold(),
68            ),
69            Span::styled(
70                ":",
71                Style::new().fg(self.theme.colors.text_primary.to_color()),
72            ),
73            Span::styled(
74                self.working_dir,
75                Style::new().fg(ratatui::style::Color::Cyan),
76            ),
77            // Padding
78            Span::raw(" ".repeat(padding_width)),
79            // Token count (fixed to right)
80            Span::styled(
81                token_text,
82                Style::new().fg(self.theme.colors.text_disabled.to_color()),
83            ),
84        ];
85
86        // Line 2: "reasoning: <level>" (or "<level> (<requested> requested)"
87        // when the user's requested level got snapped to a lower one by
88        // the model's capability ceiling) | model name (right).
89        let reasoning_text = match self.requested_level {
90            Some(requested) => format!(
91                "reasoning: {} ({} requested)",
92                self.reasoning_level.as_str(),
93                requested.as_str()
94            ),
95            None => format!("reasoning: {}", self.reasoning_level.as_str()),
96        };
97        // Prefix the app version (the one inoffensive, always-visible place we
98        // surface it) and the live safety mode (Shift+Tab / `/safety` change it
99        // live) ahead of the reasoning level.
100        let left_text = status_line2_left(self.safety_mode.as_str(), &reasoning_text);
101        let model_display = self.model_name;
102
103        // Calculate padding between reasoning text and model name (display-cell widths).
104        let left_content_width = left_text.width();
105        let right_content_width = model_display.width();
106        let padding_width_line2 = if available_width > left_content_width + right_content_width {
107            available_width - left_content_width - right_content_width
108        } else {
109            1
110        };
111
112        let line2_spans = vec![
113            // "safety: <mode> · reasoning: <level>" (left, gray, always rendered)
114            Span::styled(
115                left_text,
116                Style::new().fg(self.theme.colors.text_disabled.to_color()),
117            ),
118            // Padding to right-align model name
119            Span::raw(" ".repeat(padding_width_line2)),
120            // Model name (right, aligned with tokens above)
121            Span::styled(
122                model_display,
123                Style::new().fg(self.theme.colors.text_disabled.to_color()),
124            ),
125        ];
126
127        let line1 = Line::from(line1_spans);
128        let line2 = Line::from(line2_spans);
129        let status_bar = Paragraph::new(vec![line1, line2]);
130
131        status_bar.render(area, buf);
132    }
133}
134
135pub(crate) fn format_token_status(
136    context_usage: Option<&ContextUsageSnapshot>,
137    last_usage: Option<TokenUsageTotals>,
138    session_usage: TokenUsageTotals,
139) -> String {
140    let session = format_compact_count(session_usage.total_tokens);
141    let context = match context_usage {
142        Some(snapshot) => format_context_snapshot(snapshot),
143        None => "context: n/a".to_string(),
144    };
145    match last_usage {
146        Some(usage) => format!(
147            "{} | last api: {} | session: {}",
148            context,
149            format_compact_count(usage.total_tokens),
150            session
151        ),
152        None => format!("{} | session: {}", context, session),
153    }
154}
155
156fn format_context_snapshot(snapshot: &ContextUsageSnapshot) -> String {
157    let used = format_compact_count(snapshot.used_tokens);
158    let source = match snapshot.source {
159        TokenUsageSource::Provider => "",
160        TokenUsageSource::Estimate => "~",
161    };
162    match (snapshot.max_tokens, snapshot.used_percent) {
163        (Some(max), Some(percent)) => format!(
164            "context: {}{} / {} ({}%)",
165            source,
166            used,
167            format_compact_count(max),
168            percent
169        ),
170        _ => format!("context: {}{} / unknown", source, used),
171    }
172}
173
174fn format_compact_count(value: usize) -> String {
175    if value >= 1_000_000 {
176        format_scaled(value, 1_000_000, "m")
177    } else if value >= 10_000 {
178        format_scaled(value, 1_000, "k")
179    } else {
180        value.to_string()
181    }
182}
183
184fn format_scaled(value: usize, divisor: usize, suffix: &str) -> String {
185    let whole = value / divisor;
186    let decimal = ((value % divisor) * 10) / divisor;
187    if decimal == 0 {
188        format!("{}{}", whole, suffix)
189    } else {
190        format!("{}.{}{}", whole, decimal, suffix)
191    }
192}
193
194/// Left segment of status line 2: the app version (compile-time, so it tracks
195/// the crate version automatically), then the live safety mode and reasoning
196/// level. This footer is the single place the version is surfaced in the TUI.
197fn status_line2_left(safety: &str, reasoning_text: &str) -> String {
198    format!(
199        "mermaid v{} · safety: {} · {}",
200        env!("CARGO_PKG_VERSION"),
201        safety,
202        reasoning_text
203    )
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn status_line2_left_shows_version_safety_and_reasoning() {
212        let s = status_line2_left("ask", "reasoning: high");
213        assert!(
214            s.contains(&format!("mermaid v{}", env!("CARGO_PKG_VERSION"))),
215            "status line must show the app version — got {s:?}"
216        );
217        assert!(s.contains("safety: ask"));
218        assert!(s.contains("reasoning: high"));
219    }
220
221    #[test]
222    fn token_status_labels_last_and_session_usage() {
223        let context = ContextUsageSnapshot::from_usage(
224            &crate::models::TokenUsage::provider(12_000, 456, 12_456),
225            Some(128_000),
226        );
227        assert_eq!(
228            format_token_status(
229                Some(&context),
230                Some(TokenUsageTotals {
231                    prompt_tokens: 12_000,
232                    completion_tokens: 456,
233                    total_tokens: 12_456,
234                    ..TokenUsageTotals::default()
235                }),
236                TokenUsageTotals {
237                    prompt_tokens: 500_000,
238                    completion_tokens: 73_443,
239                    total_tokens: 573_443,
240                    ..TokenUsageTotals::default()
241                },
242            ),
243            "context: 12.4k / 128k (9%) | last api: 12.4k | session: 573.4k"
244        );
245    }
246
247    #[test]
248    fn token_status_handles_missing_last_usage() {
249        assert_eq!(
250            format_token_status(
251                None,
252                None,
253                TokenUsageTotals {
254                    prompt_tokens: 900,
255                    completion_tokens: 50,
256                    total_tokens: 950,
257                    ..TokenUsageTotals::default()
258                },
259            ),
260            "context: n/a | session: 950"
261        );
262    }
263
264    #[test]
265    fn token_status_marks_estimates() {
266        let context = ContextUsageSnapshot::from_estimate(
267            crate::domain::PromptTokenBreakdown {
268                system_tokens: 10,
269                instructions_tokens: 0,
270                message_tokens: 20,
271                tool_schema_tokens: 70,
272                image_count: 0,
273                message_count: 1,
274                tool_count: 4,
275            },
276            None,
277        );
278
279        assert_eq!(
280            format_token_status(Some(&context), None, TokenUsageTotals::default()),
281            "context: ~100 / unknown | session: 0"
282        );
283    }
284}