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, format_compact_count};
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    /// App version for the line-2 footer, threaded from `RenderCache` (like
25    /// hostname/username) so the snapshot suite can pin it.
26    pub version: &'a str,
27    pub context_usage: Option<&'a ContextUsageSnapshot>,
28    pub model_name: &'a str,
29    /// Effective reasoning depth — what the API actually saw after
30    /// `nearest_effort` snapping against the model's capabilities. Always
31    /// rendered on line 2 left.
32    pub reasoning_level: ReasoningLevel,
33    /// User-requested level when it differs from `reasoning_level` (the
34    /// snap case). `Some(requested)` shows `reasoning: high (max
35    /// requested)`; `None` shows just `reasoning: high`.
36    pub requested_level: Option<ReasoningLevel>,
37    /// Live session safety mode — including `plan`, which is a mode like any
38    /// other and renders as plain `safety: plan`. Rendered on line 2 left so
39    /// the active permission level is always visible (Shift+Tab / `/safety`
40    /// change it). Never the spinner/status widget (#245 invariant) — this is
41    /// the persistent mode line.
42    pub safety_mode: SafetyMode,
43}
44
45impl<'a> Widget for StatusWidget<'a> {
46    fn render(self, area: Rect, buf: &mut Buffer) {
47        // Line 1: username@hostname:/path (left) | token usage (right, fixed position).
48        // Host/user are resolved once at startup and passed in (#55).
49        let directory_text = format!("{}@{}:{}", self.username, self.hostname, self.working_dir);
50        let token_text = format_token_status(self.context_usage);
51
52        // Calculate padding to push tokens to right edge. Use display-cell
53        // widths so CJK / emoji chars in working_dir or hostname don't
54        // misalign the right-anchored token count.
55        let available_width = area.width as usize;
56        let directory_width = directory_text.width();
57        let token_width = token_text.width();
58        let padding_width = if available_width > directory_width + token_width + 1 {
59            available_width - directory_width - token_width
60        } else {
61            1
62        };
63
64        let line1_spans = vec![
65            // Directory (fixed to left)
66            Span::styled(
67                format!("{}@{}", self.username, self.hostname),
68                Style::new().fg(self.theme.colors.success.to_color()).bold(),
69            ),
70            Span::styled(
71                ":",
72                Style::new().fg(self.theme.colors.text_primary.to_color()),
73            ),
74            Span::styled(
75                self.working_dir,
76                Style::new().fg(self.theme.colors.info.to_color()),
77            ),
78            // Padding
79            Span::raw(" ".repeat(padding_width)),
80            // Token count (fixed to right)
81            Span::styled(
82                token_text,
83                Style::new().fg(self.theme.colors.text_disabled.to_color()),
84            ),
85        ];
86
87        // Line 2: "reasoning: <level>" (or "<level> (<requested> requested)"
88        // when the user's requested level got snapped to a lower one by
89        // the model's capability ceiling) | model name (right).
90        let reasoning_text = match self.requested_level {
91            Some(requested) => format!(
92                "reasoning: {} ({} requested)",
93                self.reasoning_level.as_str(),
94                requested.as_str()
95            ),
96            None => format!("reasoning: {}", self.reasoning_level.as_str()),
97        };
98        // Prefix the app version (the one inoffensive, always-visible place we
99        // surface it) and the live safety mode (Shift+Tab / `/safety` change it
100        // live) ahead of the reasoning level.
101        let safety_segment = format!("safety: {}", self.safety_mode.as_str());
102        let left_text = status_line2_left(self.version, &safety_segment, &reasoning_text);
103        let model_display = self.model_name;
104
105        // Calculate padding between reasoning text and model name (display-cell widths).
106        let left_content_width = left_text.width();
107        let right_content_width = model_display.width();
108        let padding_width_line2 = if available_width > left_content_width + right_content_width {
109            available_width - left_content_width - right_content_width
110        } else {
111            1
112        };
113
114        let line2_spans = vec![
115            // "safety: <mode> · reasoning: <level>" (left, gray, always rendered)
116            Span::styled(
117                left_text,
118                Style::new().fg(self.theme.colors.text_disabled.to_color()),
119            ),
120            // Padding to right-align model name
121            Span::raw(" ".repeat(padding_width_line2)),
122            // Model name (right, aligned with tokens above)
123            Span::styled(
124                model_display,
125                Style::new().fg(self.theme.colors.text_disabled.to_color()),
126            ),
127        ];
128
129        let line1 = Line::from(line1_spans);
130        let line2 = Line::from(line2_spans);
131        let status_bar = Paragraph::new(vec![line1, line2]);
132
133        status_bar.render(area, buf);
134    }
135}
136
137/// The footer shows the context gauge only: cumulative session usage is a
138/// cost-accounting number (input re-sent per API call, subagents included)
139/// that dwarfs and confuses the window meter — it lives in `/usage` with
140/// labels instead.
141pub(crate) fn format_token_status(context_usage: Option<&ContextUsageSnapshot>) -> String {
142    match context_usage {
143        Some(snapshot) => format_context_snapshot(snapshot),
144        None => "context: n/a".to_string(),
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
166/// Left segment of status line 2: the app version (threaded from
167/// `RenderCache`, which defaults it to the compile-time crate version), then
168/// the safety segment (`safety: <mode>`) and reasoning level. This footer is
169/// the single place the version is surfaced in the TUI.
170fn status_line2_left(version: &str, safety_segment: &str, reasoning_text: &str) -> String {
171    format!("mermaid v{version} · {safety_segment} · {reasoning_text}")
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn status_line2_left_shows_version_safety_and_reasoning() {
180        let s = status_line2_left(env!("CARGO_PKG_VERSION"), "safety: ask", "reasoning: high");
181        assert!(
182            s.contains(&format!("mermaid v{}", env!("CARGO_PKG_VERSION"))),
183            "status line must show the app version — got {s:?}"
184        );
185        assert!(s.contains("safety: ask"));
186        assert!(s.contains("reasoning: high"));
187    }
188
189    #[test]
190    fn status_line2_left_renders_plan_as_a_plain_safety_mode() {
191        // Plan is a safety mode like the others — no badge, no restore target.
192        // The old `plan mode on (alt+p to toggle) - restores: <mode>` band
193        // described a plan that layered ON TOP of a mode; that model is gone.
194        let s = status_line2_left("0.0.0", "safety: plan", "reasoning: high");
195        assert!(s.contains("safety: plan"));
196        assert!(!s.contains("restores"));
197        assert!(!s.contains("alt+p"));
198    }
199
200    #[test]
201    fn token_status_shows_context_gauge_only() {
202        let context = ContextUsageSnapshot::from_usage(
203            &crate::models::TokenUsage::provider(12_000, 456),
204            Some(128_000),
205        );
206        assert_eq!(
207            format_token_status(Some(&context)),
208            "context: 12.4k / 128k (9%)"
209        );
210    }
211
212    #[test]
213    fn token_status_handles_missing_context() {
214        assert_eq!(format_token_status(None), "context: n/a");
215    }
216
217    #[test]
218    fn token_status_marks_estimates() {
219        let context = ContextUsageSnapshot::from_estimate(
220            crate::domain::PromptTokenBreakdown {
221                system_tokens: 10,
222                instructions_tokens: 0,
223                message_tokens: 20,
224                tool_schema_tokens: 70,
225                image_count: 0,
226                message_count: 1,
227                tool_count: 4,
228            },
229            None,
230        );
231
232        assert_eq!(
233            format_token_status(Some(&context)),
234            "context: ~100 / unknown"
235        );
236    }
237}