mermaid_cli/render/widgets/
status.rs1use 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
15pub 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 pub reasoning_level: ReasoningLevel,
27 pub requested_level: Option<ReasoningLevel>,
31 pub safety_mode: SafetyMode,
34}
35
36impl<'a> Widget for StatusWidget<'a> {
37 fn render(self, area: Rect, buf: &mut Buffer) {
38 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 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 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 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 Span::raw(" ".repeat(padding_width)),
79 Span::styled(
81 token_text,
82 Style::new().fg(self.theme.colors.text_disabled.to_color()),
83 ),
84 ];
85
86 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 let left_text = status_line2_left(self.safety_mode.as_str(), &reasoning_text);
101 let model_display = self.model_name;
102
103 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 Span::styled(
115 left_text,
116 Style::new().fg(self.theme.colors.text_disabled.to_color()),
117 ),
118 Span::raw(" ".repeat(padding_width_line2)),
120 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
194fn 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}