recursive/tui/ui/
status.rs1use ratatui::prelude::*;
18use ratatui::widgets::Paragraph;
19
20use crate::tui::app::App;
21
22pub fn render(frame: &mut Frame, area: Rect, app: &App) {
24 let line = build_line(app);
25 let paragraph =
26 Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::DarkGray));
27 frame.render_widget(paragraph, area);
28}
29
30pub fn build_line(app: &App) -> Line<'static> {
32 let mut spans: Vec<Span<'static>> = Vec::new();
33
34 spans.push(Span::raw(" "));
36 spans.push(Span::styled(
37 "local".to_string(),
38 Style::default()
39 .fg(Color::Green)
40 .bg(Color::DarkGray)
41 .add_modifier(Modifier::BOLD),
42 ));
43
44 spans.push(separator());
46 spans.push(Span::styled(
47 app.model_name.clone(),
48 Style::default().fg(Color::Cyan).bg(Color::DarkGray),
49 ));
50
51 spans.push(separator());
53 spans.push(Span::styled(
54 format!(
55 "↑{} ↓{}",
56 human_count(app.usage.total_input),
57 human_count(app.usage.total_output),
58 ),
59 Style::default().fg(Color::White).bg(Color::DarkGray),
60 ));
61 if let Some(cost) = crate::tui::app::estimate_cost(
62 &app.model_name,
63 app.usage.total_input,
64 app.usage.total_output,
65 ) {
66 spans.push(Span::raw(" "));
67 spans.push(Span::styled(
68 format!("${cost:.4}"),
69 Style::default().fg(Color::Yellow).bg(Color::DarkGray),
70 ));
71 }
72
73 spans.push(separator());
75 spans.push(Span::styled(
76 format!("turn {}", app.turn_count),
77 Style::default().fg(Color::White).bg(Color::DarkGray),
78 ));
79
80 if app.plan_awaiting_approval {
82 spans.push(separator());
83 spans.push(Span::styled(
84 "plan: y/n".to_string(),
85 Style::default()
86 .fg(Color::Black)
87 .bg(Color::Yellow)
88 .add_modifier(Modifier::BOLD),
89 ));
90 } else if app.planning_mode_on {
91 spans.push(separator());
92 spans.push(Span::styled(
93 "plan-first".to_string(),
94 Style::default().fg(Color::Yellow).bg(Color::DarkGray),
95 ));
96 }
97
98 if let Some(started) = app.turn.started_at {
100 let elapsed = started.elapsed().as_secs_f64();
101 spans.push(separator());
102 spans.push(Span::styled(
103 format!("⏱ {:.1}s", elapsed),
104 Style::default().fg(Color::Magenta).bg(Color::DarkGray),
105 ));
106 }
107
108 Line::from(spans)
109}
110
111fn separator() -> Span<'static> {
112 Span::styled(
113 " │ ".to_string(),
114 Style::default().fg(Color::DarkGray).bg(Color::DarkGray),
115 )
116}
117
118fn human_count(n: u64) -> String {
120 if n < 1000 {
121 n.to_string()
122 } else if n < 1_000_000 {
123 format!("{:.1}k", n as f64 / 1000.0)
124 } else {
125 format!("{:.1}M", n as f64 / 1_000_000.0)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn line_text(line: &Line) -> String {
134 line.spans.iter().map(|s| s.content.as_ref()).collect()
135 }
136
137 #[test]
138 fn human_count_formats_thresholds() {
139 assert_eq!(human_count(0), "0");
140 assert_eq!(human_count(999), "999");
141 assert_eq!(human_count(1234), "1.2k");
142 assert_eq!(human_count(1_500_000), "1.5M");
143 }
144
145 #[test]
146 fn status_bar_includes_model_and_tokens() {
147 let mut app = App::new();
148 app.model_name = "deepseek-chat".to_string();
149 app.usage.total_input = 1234;
150 app.usage.total_output = 342;
151 let line = build_line(&app);
152 let text = line_text(&line);
153 assert!(text.contains("local"));
154 assert!(text.contains("deepseek-chat"));
155 assert!(text.contains("↑1.2k"));
156 assert!(text.contains("↓342"));
157 assert!(text.contains("turn"));
158 }
159
160 #[test]
161 fn status_bar_includes_cost_for_known_model() {
162 let mut app = App::new();
163 app.model_name = "gpt-4o-mini".to_string();
164 app.usage.total_input = 1000;
165 app.usage.total_output = 1000;
166 let text = line_text(&build_line(&app));
167 assert!(text.contains("$"));
168 }
169
170 #[test]
171 fn status_bar_omits_cost_for_unknown_model() {
172 let mut app = App::new();
173 app.model_name = "totally-bogus-model".to_string();
174 app.usage.total_input = 1000;
175 app.usage.total_output = 1000;
176 let text = line_text(&build_line(&app));
177 assert!(!text.contains("$"));
178 }
179
180 #[test]
181 fn status_bar_shows_elapsed_only_when_turn_running() {
182 let mut app = App::new();
183 let no_turn = line_text(&build_line(&app));
184 assert!(!no_turn.contains("⏱"));
185 app.turn.start();
186 let with_turn = line_text(&build_line(&app));
187 assert!(with_turn.contains("⏱"));
188 }
189
190 #[test]
191 fn status_bar_shows_plan_awaiting_indicator() {
192 let mut app = App::new();
193 let no_plan = line_text(&build_line(&app));
194 assert!(!no_plan.contains("plan:"));
195 app.plan_awaiting_approval = true;
196 let with_plan = line_text(&build_line(&app));
197 assert!(with_plan.contains("plan: y/n"));
198 }
199
200 #[test]
201 fn status_bar_shows_plan_first_mode() {
202 let mut app = App::new();
203 let no_mode = line_text(&build_line(&app));
204 assert!(!no_mode.contains("plan-first"));
205 app.planning_mode_on = true;
206 let with_mode = line_text(&build_line(&app));
207 assert!(with_mode.contains("plan-first"));
208 }
209}