Skip to main content

imp_tui/views/
status.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use crate::animation::AnimationState;
5use imp_core::config::AnimationLevel;
6
7use ratatui::buffer::Buffer;
8use ratatui::layout::Rect;
9use ratatui::text::{Line, Span};
10use ratatui::widgets::Widget;
11
12use crate::theme::Theme;
13
14/// Information displayed in the status bar.
15#[derive(Debug, Clone, Default)]
16pub struct StatusInfo {
17    pub cwd: String,
18    pub session_name: String,
19    pub model: String,
20    pub thinking: String,
21    pub input_tokens: u32,
22    pub output_tokens: u32,
23    pub current_context_tokens: u32,
24    pub cost: f64,
25    pub context_percent: f64,
26    pub context_window: u32,
27    pub show_cost: bool,
28    pub show_context_usage: bool,
29    pub peek: bool,
30    pub extension_items: HashMap<String, String>,
31    pub is_streaming: bool,
32    pub active_tools: u32,
33    pub turn_elapsed: Option<Duration>,
34    pub tick: u64,
35    pub animation_level: AnimationLevel,
36    pub activity_state: AnimationState,
37}
38
39/// Footer status bar: cwd | session | tokens (↑input ↓output) | cost ($X.XX) | context% | model.
40pub struct StatusBar<'a> {
41    info: &'a StatusInfo,
42    theme: &'a Theme,
43}
44
45impl<'a> StatusBar<'a> {
46    pub fn new(info: &'a StatusInfo, theme: &'a Theme) -> Self {
47        Self { info, theme }
48    }
49}
50
51impl Widget for StatusBar<'_> {
52    fn render(self, area: Rect, buf: &mut Buffer) {
53        if area.height == 0 {
54            return;
55        }
56
57        // Build left side: cwd | session
58        let cwd_short = shorten_path(&self.info.cwd, 30);
59        let mut left_parts = vec![Span::styled(cwd_short, self.theme.accent_style())];
60
61        if !self.info.session_name.is_empty() {
62            left_parts.push(Span::styled(" │ ", self.theme.muted_style()));
63            left_parts.push(Span::styled(
64                self.info.session_name.clone(),
65                self.theme.muted_style(),
66            ));
67        }
68
69        // Extension status items
70        for (key, val) in &self.info.extension_items {
71            left_parts.push(Span::styled(" │ ", self.theme.muted_style()));
72            left_parts.push(Span::styled(
73                format!("{key}: {val}"),
74                self.theme.muted_style(),
75            ));
76        }
77
78        // Build right side: tokens | cost | context% | model
79        let tokens_str = format!(
80            "↑{} ↓{}",
81            format_tokens(self.info.input_tokens),
82            format_tokens(self.info.output_tokens)
83        );
84        let cost_str = format!("${:.2}", self.info.cost);
85        let context_str = format!("{:.0}%", self.info.context_percent * 100.0);
86        // Color the context% to give an at-a-glance warning as context gets tight.
87        let context_style = if self.info.context_percent > 0.75 {
88            self.theme.error_style()
89        } else if self.info.context_percent > 0.50 {
90            self.theme.warning_style()
91        } else {
92            self.theme.muted_style()
93        };
94
95        let mut right_parts = Vec::new();
96        if self.info.peek {
97            right_parts.push(Span::styled("👁 PEEK", self.theme.accent_style()));
98            right_parts.push(Span::styled(" │ ", self.theme.muted_style()));
99        }
100        right_parts.extend([
101            Span::styled(tokens_str, self.theme.muted_style()),
102            Span::styled(" │ ", self.theme.muted_style()),
103            Span::styled(cost_str, self.theme.muted_style()),
104            Span::styled(" │ ", self.theme.muted_style()),
105            Span::styled(context_str, context_style),
106            Span::styled(" │ ", self.theme.muted_style()),
107            Span::styled(self.info.model.clone(), self.theme.accent_style()),
108        ]);
109
110        // Compute widths
111        let right_width: usize = right_parts.iter().map(|s| s.content.len()).sum();
112        let available = area.width as usize;
113
114        let line = if available > right_width + 4 {
115            // Space between left and right
116            let left_width: usize = left_parts.iter().map(|s| s.content.len()).sum();
117            let gap = available.saturating_sub(left_width + right_width);
118            let mut spans = left_parts;
119            spans.push(Span::raw(" ".repeat(gap)));
120            spans.extend(right_parts);
121            Line::from(spans)
122        } else {
123            // Just show right side if terminal is narrow
124            Line::from(right_parts)
125        };
126
127        buf.set_line(area.x, area.y, &line, area.width);
128    }
129}
130
131fn format_tokens(tokens: u32) -> String {
132    if tokens >= 1_000_000 {
133        format!("{:.1}M", tokens as f64 / 1_000_000.0)
134    } else if tokens >= 1_000 {
135        format!("{:.1}k", tokens as f64 / 1_000.0)
136    } else {
137        format!("{tokens}")
138    }
139}
140
141fn shorten_path(path: &str, max_len: usize) -> String {
142    if path.len() <= max_len {
143        return path.to_string();
144    }
145    // Try to show just the last N components
146    let parts: Vec<&str> = path.split('/').collect();
147    let mut result = String::new();
148    for part in parts.iter().rev() {
149        let candidate = if result.is_empty() {
150            part.to_string()
151        } else {
152            format!("{part}/{result}")
153        };
154        if candidate.len() > max_len {
155            break;
156        }
157        result = candidate;
158    }
159    if result.len() < path.len() {
160        format!("…/{result}")
161    } else {
162        result
163    }
164}