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