Skip to main content

vtcode_ui/tui/core_tui/widgets/
footer.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::Modifier,
5    text::{Line, Span},
6    widgets::{Block, Clear, Paragraph, Widget},
7};
8
9use super::layout_mode::LayoutMode;
10use super::panel::PanelStyles;
11use crate::tui::core_tui::language_badge::language_badge_style;
12use crate::tui::ui::tui::session::styling::SessionStyles;
13use crate::tui::ui::tui::session::terminal_capabilities;
14use tui_shimmer::shimmer_spans_with_style_at_phase;
15
16use crate::tui::ui::tui::session::status_requires_shimmer;
17
18/// Widget for rendering the footer area with status and hints
19///
20/// The footer provides a stable region for:
21/// - Left status (git branch, model info)
22/// - Right status (token count, context usage)
23/// - Help hints (shown conditionally)
24///
25/// # Example
26/// ```ignore
27/// FooterWidget::new(&styles)
28///     .left_status("main ✓")
29///     .right_status("claude-4 | 12K tokens")
30///     .hint("? for help")
31///     .mode(LayoutMode::Standard)
32///     .render(footer_area, buf);
33/// ```
34pub struct FooterWidget<'a> {
35    styles: &'a SessionStyles,
36    left_status: Option<&'a str>,
37    right_status: Option<&'a str>,
38    hint: Option<&'a str>,
39    mode: LayoutMode,
40    show_border: bool,
41    spinner: Option<&'a str>,
42    shimmer_phase: Option<f32>,
43}
44
45impl<'a> FooterWidget<'a> {
46    /// Create a new footer widget
47    pub(crate) fn new(styles: &'a SessionStyles) -> Self {
48        Self {
49            styles,
50            left_status: None,
51            right_status: None,
52            hint: None,
53            mode: LayoutMode::Standard,
54            show_border: false,
55            spinner: None,
56            shimmer_phase: None,
57        }
58    }
59
60    /// Set the left status text (e.g., git branch)
61    #[must_use]
62    pub(crate) fn left_status(mut self, status: &'a str) -> Self {
63        self.left_status = Some(status);
64        self
65    }
66
67    /// Set the right status text (e.g., model info)
68    #[must_use]
69    pub(crate) fn right_status(mut self, status: &'a str) -> Self {
70        self.right_status = Some(status);
71        self
72    }
73
74    /// Set the hint text (shown when idle)
75    #[must_use]
76    pub(crate) fn hint(mut self, hint: &'a str) -> Self {
77        self.hint = Some(hint);
78        self
79    }
80
81    /// Set the layout mode
82    #[must_use]
83    pub(crate) fn mode(mut self, mode: LayoutMode) -> Self {
84        self.mode = mode;
85        self
86    }
87
88    /// Show a top border
89    #[must_use]
90    pub fn show_border(mut self, show: bool) -> Self {
91        self.show_border = show;
92        self
93    }
94
95    /// Set spinner text (shown when processing)
96    #[must_use]
97    pub(crate) fn spinner(mut self, spinner: &'a str) -> Self {
98        self.spinner = Some(spinner);
99        self
100    }
101
102    /// Set shimmer phase for animated status text
103    #[must_use]
104    pub(crate) fn shimmer_phase(mut self, phase: f32) -> Self {
105        self.shimmer_phase = Some(phase);
106        self
107    }
108
109    fn build_status_line(&self, width: u16) -> Line<'static> {
110        if width == 0 {
111            return Line::default();
112        }
113        let mut spans = Vec::new();
114
115        // Left status
116        if let Some(left) = self.left_status {
117            if status_requires_shimmer(left) {
118                if let Some(phase) = self.shimmer_phase {
119                    spans.extend(shimmer_spans_with_style_at_phase(
120                        left,
121                        self.styles.accent_style().add_modifier(Modifier::DIM),
122                        phase,
123                    ));
124                } else {
125                    spans.push(Span::styled(left.to_string(), self.styles.muted_style()));
126                }
127            } else {
128                spans.push(Span::styled(left.to_string(), self.styles.accent_style()));
129            }
130        }
131
132        // Spinner (if active)
133        if let Some(spinner) = self.spinner {
134            if !spans.is_empty() {
135                spans.push(Span::raw(" "));
136            }
137            spans.push(Span::styled(spinner.to_string(), self.styles.muted_style()));
138        }
139
140        // Reserve right-side content before fitting variable left activity.
141        // Measure terminal columns rather than bytes (Git/context may be Unicode).
142        let right = crate::tui::core_tui::session::utils::line_truncation::truncate_line_with_ellipsis_if_overflow(
143            Line::from(self.build_right_status_spans(self.right_status.unwrap_or(""))),
144            usize::from(width),
145        );
146        let right_width = right.width();
147        let left_budget = usize::from(width).saturating_sub(right_width + usize::from(right_width > 0));
148        spans = if left_budget == 0 {
149            Vec::new()
150        } else {
151            crate::tui::core_tui::session::utils::line_truncation::truncate_line_with_ellipsis_if_overflow(
152                Line::from(spans),
153                left_budget,
154            )
155            .spans
156        };
157        if right_width > 0 {
158            let left_width = Line::from(spans.clone()).width();
159            spans.push(Span::raw(" ".repeat(usize::from(width).saturating_sub(left_width + right_width))));
160            spans.extend(right.spans);
161        }
162
163        Line::from(spans)
164    }
165
166    fn build_right_status_spans(&self, status: &str) -> Vec<Span<'static>> {
167        let mut spans = Vec::new();
168        let mut parts = status.split(" · ").peekable();
169
170        while let Some(part) = parts.next() {
171            let style = language_badge_style(part).unwrap_or_else(|| self.styles.muted_style());
172            spans.push(Span::styled(part.to_string(), style));
173
174            if parts.peek().is_some() {
175                spans.push(Span::styled(" · ".to_string(), self.styles.muted_style()));
176            }
177        }
178
179        spans
180    }
181
182    fn build_hint_line(&self) -> Option<Line<'static>> {
183        match self.mode {
184            LayoutMode::Compact => None,
185            _ => self
186                .hint
187                .map(|hint| Line::from(Span::styled(hint.to_string(), self.styles.muted_style()))),
188        }
189    }
190}
191
192impl Widget for FooterWidget<'_> {
193    fn render(self, area: Rect, buf: &mut Buffer) {
194        if area.height == 0 || area.width == 0 {
195            return;
196        }
197
198        Clear.render(area, buf);
199
200        let inner = if self.show_border && self.mode.show_borders() {
201            let block = Block::bordered()
202                .border_type(terminal_capabilities::get_border_type())
203                .border_style(self.styles.border_style());
204            let inner = block.inner(area);
205            block.render(area, buf);
206            inner
207        } else {
208            area
209        };
210
211        if inner.height == 0 {
212            return;
213        }
214
215        let status_line = self.build_status_line(inner.width);
216        let hint_line = self.build_hint_line();
217
218        let lines: Vec<Line<'static>> = if inner.height >= 2 {
219            if let Some(hint) = hint_line {
220                vec![status_line, hint]
221            } else {
222                vec![status_line]
223            }
224        } else {
225            vec![status_line]
226        };
227
228        let paragraph = Paragraph::new(lines);
229        paragraph.render(inner, buf);
230    }
231}
232
233/// Default keybind hints for different contexts
234pub mod hints {
235    pub(crate) const IDLE: &str = "? help • / command • @ file • Tab agent";
236    pub(crate) const PROCESSING: &str = vtcode_commons::stop_hints::STOP_HINT_COMPACT;
237    pub(crate) const MODAL: &str = "↑↓ navigate • Enter select • Esc close";
238    pub(crate) const EDITING: &str = "Enter run • Ctrl+Enter queue • /stop • ↑ history";
239    pub(crate) const BLOCKED: &str =
240        "Blocked • continue to retry • /resume • details: .vtcode/tasks/current_blocked.md";
241    pub(crate) const RECOVERY: &str = "Recovery: tools disabled • synthesizing text response…";
242}
243
244#[cfg(test)]
245mod tests {
246    use super::FooterWidget;
247    use crate::tui::core_tui::session::styling::SessionStyles;
248    use crate::tui::ui::tui::types::InlineTheme;
249    use ratatui::style::Color;
250
251    #[test]
252    fn build_right_status_spans_highlights_dominant_language() {
253        let styles = SessionStyles::new(InlineTheme::default());
254        let widget = FooterWidget::new(&styles);
255
256        let spans = widget.build_right_status_spans("Rust · model · 17% context left");
257
258        assert_eq!(spans[0].content.as_ref(), "Rust");
259        assert_eq!(spans[0].style.fg, Some(Color::Rgb(0xCE, 0x7E, 0x47)));
260        assert_eq!(spans[2].content.as_ref(), "model");
261        assert_ne!(spans[2].style.fg, Some(Color::Rgb(0xCE, 0x7E, 0x47)));
262    }
263
264    #[test]
265    fn build_status_line_uses_shimmer_phase_for_visible_running_status() {
266        let styles = SessionStyles::new(InlineTheme::default());
267        let widget = FooterWidget::new(&styles).left_status("Approval required").shimmer_phase(0.5);
268
269        let line = widget.build_status_line(40);
270
271        assert!(line.spans.len() > 1);
272        let rendered = line.spans.iter().map(|span| span.content.as_ref()).collect::<String>();
273        assert!(rendered.contains("Approval required"));
274    }
275
276    #[test]
277    fn zero_left_allocation_emits_only_reserved_right_content() {
278        let styles = SessionStyles::new(InlineTheme::default());
279        let widget = FooterWidget::new(&styles).left_status("topic/stable*").right_status("• Build");
280        for (width, expected) in [(0, ""), (6, "• Bui…"), (7, "• Build"), (8, " • Build")] {
281            let line = widget.build_status_line(width);
282            assert_eq!(line.to_string(), expected);
283            assert!(line.width() <= usize::from(width));
284        }
285    }
286}