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,
43}
44
45impl<'a> Widget for StatusWidget<'a> {
46 fn render(self, area: Rect, buf: &mut Buffer) {
47 let directory_text = format!("{}@{}:{}", self.username, self.hostname, self.working_dir);
50 let token_text = format_token_status(self.context_usage);
51
52 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 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 Span::raw(" ".repeat(padding_width)),
80 Span::styled(
82 token_text,
83 Style::new().fg(self.theme.colors.text_disabled.to_color()),
84 ),
85 ];
86
87 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 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 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 Span::styled(
117 left_text,
118 Style::new().fg(self.theme.colors.text_disabled.to_color()),
119 ),
120 Span::raw(" ".repeat(padding_width_line2)),
122 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
137pub(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
166fn 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 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}