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 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 pub reasoning_level: ReasoningLevel,
32 pub requested_level: Option<ReasoningLevel>,
36 pub safety_mode: SafetyMode,
39}
40
41impl<'a> Widget for StatusWidget<'a> {
42 fn render(self, area: Rect, buf: &mut Buffer) {
43 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 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 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 Span::raw(" ".repeat(padding_width)),
77 Span::styled(
79 token_text,
80 Style::new().fg(self.theme.colors.text_disabled.to_color()),
81 ),
82 ];
83
84 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 let left_text = status_line2_left(self.safety_mode.as_str(), &reasoning_text);
99 let model_display = self.model_name;
100
101 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 Span::styled(
113 left_text,
114 Style::new().fg(self.theme.colors.text_disabled.to_color()),
115 ),
116 Span::raw(" ".repeat(padding_width_line2)),
118 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
192fn 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}