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 super::truncate_to_cells;
11use crate::render::theme::Theme;
12use mermaid_domain::ContextUsageSnapshot;
13use mermaid_model::models::ReasoningLevel;
14use mermaid_model::safety::SafetyMode;
15
16pub struct StatusWidget<'a> {
23 pub theme: &'a Theme,
24 pub context_usage: Option<&'a ContextUsageSnapshot>,
25 pub model_name: &'a str,
26 pub reasoning_level: ReasoningLevel,
29 pub requested_level: Option<ReasoningLevel>,
32 pub safety_mode: SafetyMode,
36}
37
38impl<'a> Widget for StatusWidget<'a> {
39 fn render(self, area: Rect, buf: &mut Buffer) {
40 let width = area.width as usize;
41 let left = footer_left(self.safety_mode, self.reasoning_level, self.requested_level);
42 let right = footer_right(
43 self.model_name,
44 context_segment(self.context_usage).as_deref(),
45 width.saturating_sub(left.width() + 1),
46 );
47 let gap = width.saturating_sub(left.width() + right.width()).max(1);
48 let meta = Style::new().fg(self.theme.colors.text_meta.to_color());
49 let line = Line::from(vec![
50 Span::styled(left, meta),
51 Span::raw(" ".repeat(gap)),
52 Span::styled(right, meta),
53 ]);
54 Paragraph::new(vec![line]).render(area, buf);
55 }
56}
57
58pub(crate) fn footer_left(
60 safety_mode: SafetyMode,
61 reasoning_level: ReasoningLevel,
62 requested_level: Option<ReasoningLevel>,
63) -> String {
64 let reasoning = match requested_level {
65 Some(requested) => format!(
66 "reasoning: {} ({} requested)",
67 reasoning_level.as_str(),
68 requested.as_str()
69 ),
70 None => format!("reasoning: {}", reasoning_level.as_str()),
71 };
72 format!("safety: {} · {reasoning}", safety_mode.as_str())
73}
74
75pub(crate) fn context_segment(context_usage: Option<&ContextUsageSnapshot>) -> Option<String> {
79 let snapshot = context_usage?;
80 let percent = snapshot.used_percent?;
81 Some(format!("context {percent}%"))
82}
83
84pub(crate) fn footer_right(model: &str, context: Option<&str>, budget: usize) -> String {
89 let assemble = |model: &str| match context {
90 Some(ctx) => format!("{model} · {ctx}"),
91 None => model.to_string(),
92 };
93 let full = assemble(model);
94 if full.width() <= budget {
95 return full;
96 }
97 let short = model.split_once('/').map_or(model, |(_, rest)| rest);
98 let shorter = assemble(short);
99 if shorter.width() <= budget {
100 return shorter;
101 }
102 truncate_to_cells(&shorter, budget)
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn footer_left_shows_safety_and_reasoning() {
111 let s = footer_left(SafetyMode::Ask, ReasoningLevel::High, None);
112 assert_eq!(s, "safety: ask · reasoning: high");
113 let s = footer_left(
114 SafetyMode::Ask,
115 ReasoningLevel::High,
116 Some(ReasoningLevel::Max),
117 );
118 assert_eq!(s, "safety: ask · reasoning: high (max requested)");
119 }
120
121 #[test]
122 fn footer_left_renders_plan_as_a_plain_safety_mode() {
123 let s = footer_left(SafetyMode::Plan, ReasoningLevel::Medium, None);
124 assert!(s.starts_with("safety: plan"), "{s}");
125 assert!(!s.contains("restores"));
126 }
127
128 #[test]
129 fn context_segment_is_absent_until_usage_is_known() {
130 assert_eq!(context_segment(None), None);
131 let estimate = ContextUsageSnapshot::from_estimate(
132 mermaid_domain::PromptTokenBreakdown {
133 system_tokens: 10,
134 instructions_tokens: 0,
135 message_tokens: 20,
136 tool_schema_tokens: 70,
137 image_count: 0,
138 message_count: 1,
139 tool_count: 4,
140 },
141 None,
142 );
143 assert_eq!(
144 context_segment(Some(&estimate)),
145 None,
146 "no window, no percent"
147 );
148 let known = ContextUsageSnapshot::from_usage(
149 &mermaid_model::models::TokenUsage::provider(12_000, 456),
150 Some(128_000),
151 );
152 assert_eq!(context_segment(Some(&known)).as_deref(), Some("context 9%"));
153 }
154
155 #[test]
156 fn footer_right_drops_the_provider_before_it_truncates() {
157 let full = footer_right("anthropic/claude-sonnet-4-5", Some("context 100%"), 80);
158 assert_eq!(full, "anthropic/claude-sonnet-4-5 · context 100%");
159 let squeezed = footer_right("anthropic/claude-sonnet-4-5", Some("context 100%"), 34);
160 assert_eq!(squeezed, "claude-sonnet-4-5 · context 100%");
161 let cut = footer_right("anthropic/claude-sonnet-4-5", Some("context 100%"), 12);
162 assert!(cut.width() <= 12, "{cut}");
163 }
164}