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, format_compact_count};
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 version: &'a str,
27 pub context_usage: Option<&'a ContextUsageSnapshot>,
28 pub model_name: &'a str,
29 pub reasoning_level: ReasoningLevel,
33 pub requested_level: Option<ReasoningLevel>,
37 pub safety_mode: SafetyMode,
40 pub plan_resume: Option<SafetyMode>,
51}
52
53impl<'a> Widget for StatusWidget<'a> {
54 fn render(self, area: Rect, buf: &mut Buffer) {
55 let directory_text = format!("{}@{}:{}", self.username, self.hostname, self.working_dir);
58 let token_text = format_token_status(self.context_usage);
59
60 let available_width = area.width as usize;
64 let directory_width = directory_text.width();
65 let token_width = token_text.width();
66 let padding_width = if available_width > directory_width + token_width + 1 {
67 available_width - directory_width - token_width
68 } else {
69 1
70 };
71
72 let line1_spans = vec![
73 Span::styled(
75 format!("{}@{}", self.username, self.hostname),
76 Style::new().fg(self.theme.colors.success.to_color()).bold(),
77 ),
78 Span::styled(
79 ":",
80 Style::new().fg(self.theme.colors.text_primary.to_color()),
81 ),
82 Span::styled(
83 self.working_dir,
84 Style::new().fg(self.theme.colors.info.to_color()),
85 ),
86 Span::raw(" ".repeat(padding_width)),
88 Span::styled(
90 token_text,
91 Style::new().fg(self.theme.colors.text_disabled.to_color()),
92 ),
93 ];
94
95 let reasoning_text = match self.requested_level {
99 Some(requested) => format!(
100 "reasoning: {} ({} requested)",
101 self.reasoning_level.as_str(),
102 requested.as_str()
103 ),
104 None => format!("reasoning: {}", self.reasoning_level.as_str()),
105 };
106 let safety_segment = match self.plan_resume {
110 Some(resume) => format!(
111 "plan mode on (alt+p to toggle) - restores: {}",
112 resume.as_str()
113 ),
114 None => format!("safety: {}", self.safety_mode.as_str()),
115 };
116 let left_text = status_line2_left(self.version, &safety_segment, &reasoning_text);
117 let model_display = self.model_name;
118
119 let left_content_width = left_text.width();
121 let right_content_width = model_display.width();
122 let padding_width_line2 = if available_width > left_content_width + right_content_width {
123 available_width - left_content_width - right_content_width
124 } else {
125 1
126 };
127
128 let line2_spans = vec![
129 Span::styled(
131 left_text,
132 Style::new().fg(self.theme.colors.text_disabled.to_color()),
133 ),
134 Span::raw(" ".repeat(padding_width_line2)),
136 Span::styled(
138 model_display,
139 Style::new().fg(self.theme.colors.text_disabled.to_color()),
140 ),
141 ];
142
143 let line1 = Line::from(line1_spans);
144 let line2 = Line::from(line2_spans);
145 let status_bar = Paragraph::new(vec![line1, line2]);
146
147 status_bar.render(area, buf);
148 }
149}
150
151pub(crate) fn format_token_status(context_usage: Option<&ContextUsageSnapshot>) -> String {
156 match context_usage {
157 Some(snapshot) => format_context_snapshot(snapshot),
158 None => "context: n/a".to_string(),
159 }
160}
161
162fn format_context_snapshot(snapshot: &ContextUsageSnapshot) -> String {
163 let used = format_compact_count(snapshot.used_tokens);
164 let source = match snapshot.source {
165 TokenUsageSource::Provider => "",
166 TokenUsageSource::Estimate => "~",
167 };
168 match (snapshot.max_tokens, snapshot.used_percent) {
169 (Some(max), Some(percent)) => format!(
170 "context: {}{} / {} ({}%)",
171 source,
172 used,
173 format_compact_count(max),
174 percent
175 ),
176 _ => format!("context: {}{} / unknown", source, used),
177 }
178}
179
180fn status_line2_left(version: &str, safety_segment: &str, reasoning_text: &str) -> String {
185 format!("mermaid v{version} · {safety_segment} · {reasoning_text}")
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn status_line2_left_shows_version_safety_and_reasoning() {
194 let s = status_line2_left(env!("CARGO_PKG_VERSION"), "safety: ask", "reasoning: high");
195 assert!(
196 s.contains(&format!("mermaid v{}", env!("CARGO_PKG_VERSION"))),
197 "status line must show the app version — got {s:?}"
198 );
199 assert!(s.contains("safety: ask"));
200 assert!(s.contains("reasoning: high"));
201 }
202
203 #[test]
204 fn status_line2_left_carries_plan_badge_segment() {
205 let s = status_line2_left(
208 "0.0.0",
209 "plan mode on (alt+p to toggle) - restores: auto",
210 "reasoning: high",
211 );
212 assert!(s.contains("plan mode on (alt+p to toggle) - restores: auto"));
213 assert!(!s.contains("safety:"));
214 }
215
216 #[test]
217 fn token_status_shows_context_gauge_only() {
218 let context = ContextUsageSnapshot::from_usage(
219 &crate::models::TokenUsage::provider(12_000, 456),
220 Some(128_000),
221 );
222 assert_eq!(
223 format_token_status(Some(&context)),
224 "context: 12.4k / 128k (9%)"
225 );
226 }
227
228 #[test]
229 fn token_status_handles_missing_context() {
230 assert_eq!(format_token_status(None), "context: n/a");
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)),
250 "context: ~100 / unknown"
251 );
252 }
253}