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