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