Skip to main content

recursive/tui/ui/
status.rs

1//! Bottom status-bar renderer.
2//!
3//! The status bar is the dense one-liner that summarises the agent's
4//! current connection, model, token usage, cost, turn counter and
5//! per-turn elapsed time. It is rendered below the transcript on the
6//! chat screen.
7//!
8//! Format (segments separated by ` │ `):
9//!
10//! ```text
11//!  local │ deepseek-chat │ ↑1.2k ↓342  $0.0024 │ turn 3 │ ⏱ 2.3s
12//! ```
13//!
14//! Cost is omitted when the model has no pricing entry in `providers.toml`;
15//! elapsed time is shown only while a turn is running.
16
17use ratatui::prelude::*;
18use ratatui::widgets::Paragraph;
19
20use crate::tui::app::App;
21
22/// Build the styled status-bar paragraph for the given [`App`].
23pub 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
30/// Public for tests — produces the styled `Line` without rendering.
31pub fn build_line(app: &App) -> Line<'static> {
32    let mut spans: Vec<Span<'static>> = Vec::new();
33
34    // [connection]
35    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    // [model]
45    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    // [tokens + cost]
52    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    // [turn]
74    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    // [plan mode indicators]
81    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    // [elapsed] — only while turn is running
99    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
118/// Format an integer compactly: 1234 → "1.2k", 1_500_000 → "1.5M".
119fn 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}