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_active: bool,
45}
46
47impl<'a> Widget for StatusWidget<'a> {
48 fn render(self, area: Rect, buf: &mut Buffer) {
49 let directory_text = format!("{}@{}:{}", self.username, self.hostname, self.working_dir);
52 let token_text = format_token_status(self.context_usage);
53
54 let available_width = area.width as usize;
58 let directory_width = directory_text.width();
59 let token_width = token_text.width();
60 let padding_width = if available_width > directory_width + token_width + 1 {
61 available_width - directory_width - token_width
62 } else {
63 1
64 };
65
66 let line1_spans = vec![
67 Span::styled(
69 format!("{}@{}", self.username, self.hostname),
70 Style::new().fg(self.theme.colors.success.to_color()).bold(),
71 ),
72 Span::styled(
73 ":",
74 Style::new().fg(self.theme.colors.text_primary.to_color()),
75 ),
76 Span::styled(
77 self.working_dir,
78 Style::new().fg(self.theme.colors.info.to_color()),
79 ),
80 Span::raw(" ".repeat(padding_width)),
82 Span::styled(
84 token_text,
85 Style::new().fg(self.theme.colors.text_disabled.to_color()),
86 ),
87 ];
88
89 let reasoning_text = match self.requested_level {
93 Some(requested) => format!(
94 "reasoning: {} ({} requested)",
95 self.reasoning_level.as_str(),
96 requested.as_str()
97 ),
98 None => format!("reasoning: {}", self.reasoning_level.as_str()),
99 };
100 let safety_segment = if self.plan_active {
104 format!(
105 "plan mode on (alt+p to toggle) - restores: {}",
106 self.safety_mode.as_str()
107 )
108 } else {
109 format!("safety: {}", self.safety_mode.as_str())
110 };
111 let left_text = status_line2_left(self.version, &safety_segment, &reasoning_text);
112 let model_display = self.model_name;
113
114 let left_content_width = left_text.width();
116 let right_content_width = model_display.width();
117 let padding_width_line2 = if available_width > left_content_width + right_content_width {
118 available_width - left_content_width - right_content_width
119 } else {
120 1
121 };
122
123 let line2_spans = vec![
124 Span::styled(
126 left_text,
127 Style::new().fg(self.theme.colors.text_disabled.to_color()),
128 ),
129 Span::raw(" ".repeat(padding_width_line2)),
131 Span::styled(
133 model_display,
134 Style::new().fg(self.theme.colors.text_disabled.to_color()),
135 ),
136 ];
137
138 let line1 = Line::from(line1_spans);
139 let line2 = Line::from(line2_spans);
140 let status_bar = Paragraph::new(vec![line1, line2]);
141
142 status_bar.render(area, buf);
143 }
144}
145
146pub(crate) fn format_token_status(context_usage: Option<&ContextUsageSnapshot>) -> String {
151 match context_usage {
152 Some(snapshot) => format_context_snapshot(snapshot),
153 None => "context: n/a".to_string(),
154 }
155}
156
157fn format_context_snapshot(snapshot: &ContextUsageSnapshot) -> String {
158 let used = format_compact_count(snapshot.used_tokens);
159 let source = match snapshot.source {
160 TokenUsageSource::Provider => "",
161 TokenUsageSource::Estimate => "~",
162 };
163 match (snapshot.max_tokens, snapshot.used_percent) {
164 (Some(max), Some(percent)) => format!(
165 "context: {}{} / {} ({}%)",
166 source,
167 used,
168 format_compact_count(max),
169 percent
170 ),
171 _ => format!("context: {}{} / unknown", source, used),
172 }
173}
174
175fn status_line2_left(version: &str, safety_segment: &str, reasoning_text: &str) -> String {
180 format!("mermaid v{version} · {safety_segment} · {reasoning_text}")
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn status_line2_left_shows_version_safety_and_reasoning() {
189 let s = status_line2_left(env!("CARGO_PKG_VERSION"), "safety: ask", "reasoning: high");
190 assert!(
191 s.contains(&format!("mermaid v{}", env!("CARGO_PKG_VERSION"))),
192 "status line must show the app version — got {s:?}"
193 );
194 assert!(s.contains("safety: ask"));
195 assert!(s.contains("reasoning: high"));
196 }
197
198 #[test]
199 fn status_line2_left_carries_plan_badge_segment() {
200 let s = status_line2_left(
203 "0.0.0",
204 "plan mode on (alt+p to toggle) - restores: auto",
205 "reasoning: high",
206 );
207 assert!(s.contains("plan mode on (alt+p to toggle) - restores: auto"));
208 assert!(!s.contains("safety:"));
209 }
210
211 #[test]
212 fn token_status_shows_context_gauge_only() {
213 let context = ContextUsageSnapshot::from_usage(
214 &crate::models::TokenUsage::provider(12_000, 456),
215 Some(128_000),
216 );
217 assert_eq!(
218 format_token_status(Some(&context)),
219 "context: 12.4k / 128k (9%)"
220 );
221 }
222
223 #[test]
224 fn token_status_handles_missing_context() {
225 assert_eq!(format_token_status(None), "context: n/a");
226 }
227
228 #[test]
229 fn token_status_marks_estimates() {
230 let context = ContextUsageSnapshot::from_estimate(
231 crate::domain::PromptTokenBreakdown {
232 system_tokens: 10,
233 instructions_tokens: 0,
234 message_tokens: 20,
235 tool_schema_tokens: 70,
236 image_count: 0,
237 message_count: 1,
238 tool_count: 4,
239 },
240 None,
241 );
242
243 assert_eq!(
244 format_token_status(Some(&context)),
245 "context: ~100 / unknown"
246 );
247 }
248}