Skip to main content

cove_cli/sidebar/
ui.rs

1// ── ratatui rendering for sidebar ──
2
3use std::collections::HashMap;
4
5use ratatui::buffer::Buffer;
6use ratatui::layout::Rect;
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::Widget;
10
11use crate::colors;
12use crate::sidebar::state::WindowState;
13use crate::tmux::WindowInfo;
14
15// ── Types ──
16
17/// Legend entry for keyboard shortcuts.
18struct LegendEntry {
19    key: &'static str,
20    label: &'static str,
21}
22
23const LEGEND: &[LegendEntry] = &[
24    LegendEntry {
25        key: "\u{2318} + j",
26        label: "claude",
27    },
28    LegendEntry {
29        key: "\u{2318} + m",
30        label: "terminal",
31    },
32    LegendEntry {
33        key: "\u{2318} + p",
34        label: "sessions",
35    },
36    LegendEntry {
37        key: "\u{2318} + ;",
38        label: "detach",
39    },
40];
41
42pub struct SidebarWidget<'a> {
43    pub windows: &'a [WindowInfo],
44    pub states: &'a HashMap<u32, WindowState>,
45    pub selected: usize,
46    pub tick: u64,
47    /// Context description for the selected session (if available).
48    pub context: Option<&'a str>,
49    /// Whether context is currently being generated for the selected session.
50    pub context_loading: bool,
51    /// Error message when context generation failed (e.g. "Ollama not connected").
52    pub context_error: Option<&'a str>,
53}
54
55// ── Public API ──
56
57impl Widget for SidebarWidget<'_> {
58    fn render(self, area: Rect, buf: &mut Buffer) {
59        let window_count = self.windows.len();
60
61        // ── Header ──
62        let plural = if window_count == 1 { "" } else { "s" };
63        let header = Line::from(vec![
64            Span::raw(" "),
65            Span::styled(
66                format!("{window_count} session{plural}"),
67                Style::default().fg(colors::OVERLAY),
68            ),
69            Span::styled(" \u{00b7} ", Style::default().fg(colors::SURFACE)),
70            Span::styled("\u{2191}\u{2193}", Style::default().fg(colors::BLUE)),
71            Span::styled(" navigate", Style::default().fg(colors::OVERLAY)),
72        ]);
73        if area.height > 0 {
74            buf.set_line(area.x, area.y, &header, area.width);
75        }
76
77        // ── Separator ──
78        if area.height > 1 {
79            let sep_row = area.y + 1;
80            for x in area.x..area.x + area.width {
81                buf.cell_mut((x, sep_row))
82                    .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
83            }
84        }
85
86        // ── Body ──
87        let body_start = area.y + 2;
88        let right_col = area.width.saturating_sub(15);
89
90        // Pre-calculate context block height so we can reserve space at the bottom
91        let context_height = if let Some(context) = self.context {
92            let max_width = (area.width as usize).saturating_sub(3);
93            let text_lines = wrap_text(context, max_width, 3).len() as u16;
94            1 + text_lines // dashed separator + text lines
95        } else if self.context_loading || self.context_error.is_some() {
96            2 // dashed separator + "loading…" or error message
97        } else {
98            0
99        };
100
101        // Left column: sessions
102        let body_bottom = area.y + area.height;
103        let session_bottom = body_bottom.saturating_sub(context_height);
104        for (i, win) in self.windows.iter().enumerate() {
105            let y = body_start + i as u16;
106            if y >= session_bottom {
107                break;
108            }
109
110            let state = self
111                .states
112                .get(&win.index)
113                .copied()
114                .unwrap_or(WindowState::Fresh);
115            let is_selected = i == self.selected;
116
117            let (bullet, name_style) = if is_selected {
118                (
119                    Span::styled("\u{276f}", Style::default().fg(Color::White)),
120                    Style::default().fg(Color::White),
121                )
122            } else {
123                (Span::raw(" "), Style::default().fg(colors::OVERLAY))
124            };
125
126            let mut spans = vec![
127                Span::raw(" "),
128                bullet,
129                Span::raw(" "),
130                Span::styled(&win.name, name_style),
131            ];
132
133            let status = status_text(state);
134            if matches!(state, WindowState::Working) {
135                // Spinner renders inline right after the name
136                spans.push(status_span(state, self.tick));
137            } else if !status.is_empty() {
138                // Right-align status text against the legend column
139                let name_width = 3 + win.name.len(); // " · " or " ❯ " prefix + name
140                let status_width = status.chars().count() + 2; // 2 spaces before status
141                let pad = (right_col as usize).saturating_sub(name_width + status_width);
142                spans.push(Span::raw(" ".repeat(pad)));
143                spans.push(status_span(state, self.tick));
144            }
145
146            let line = Line::from(spans);
147            buf.set_line(area.x, y, &line, right_col);
148        }
149
150        // Right column: legend (independent positioning)
151        for (i, entry) in LEGEND.iter().enumerate() {
152            let ly = body_start + i as u16;
153            if ly >= area.y + area.height {
154                break;
155            }
156            let legend_line = Line::from(vec![
157                Span::styled(entry.key, Style::default().fg(colors::BLUE)),
158                Span::raw("  "),
159                Span::styled(entry.label, Style::default().fg(colors::OVERLAY)),
160            ]);
161            buf.set_line(area.x + right_col, ly, &legend_line, area.width - right_col);
162        }
163
164        // Context block pinned to the bottom of the panel
165        if context_height > 0 {
166            let context_start = body_bottom.saturating_sub(context_height);
167            if context_start >= body_start {
168                if let Some(context) = self.context {
169                    render_context_block(buf, area, context_start, right_col, context);
170                } else if self.context_loading {
171                    render_loading_block(buf, area, context_start);
172                } else if let Some(error) = self.context_error {
173                    render_error_block(buf, area, context_start, error);
174                }
175            }
176        }
177    }
178}
179
180// ── Helpers ──
181
182const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
183
184fn status_text(state: WindowState) -> &'static str {
185    match state {
186        WindowState::Working => "",
187        WindowState::Asking => "waiting\u{2026}",
188        WindowState::Waiting => "approve\u{2026}",
189        WindowState::Idle => "your turn",
190        WindowState::Done => "",
191        WindowState::Fresh => "",
192    }
193}
194
195fn status_span(state: WindowState, tick: u64) -> Span<'static> {
196    match state {
197        WindowState::Working => {
198            let frame = SPINNER[tick as usize % SPINNER.len()];
199            Span::styled(format!(" {frame}"), Style::default().fg(colors::LAVENDER))
200        }
201        WindowState::Idle => Span::styled(status_text(state), Style::default().fg(colors::GREEN)),
202        WindowState::Waiting => Span::styled(
203            status_text(state),
204            Style::default()
205                .fg(colors::PEACH)
206                .add_modifier(Modifier::ITALIC),
207        ),
208        _ => Span::styled(
209            status_text(state),
210            Style::default()
211                .fg(colors::OVERLAY)
212                .add_modifier(Modifier::ITALIC),
213        ),
214    }
215}
216
217/// Render the dashed separator + context text below the selected session.
218fn render_context_block(buf: &mut Buffer, area: Rect, mut y: u16, _right_col: u16, text: &str) {
219    // Dashed separator (full width with 1-char padding on each side)
220    if y < area.y + area.height {
221        for x in (area.x + 1)..area.x + area.width.saturating_sub(1) {
222            buf.cell_mut((x, y))
223                .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
224        }
225        y += 1;
226    }
227
228    // Word-wrapped context (max 3 lines)
229    let max_width = (area.width as usize).saturating_sub(3);
230    let lines = wrap_text(text, max_width, 3);
231    for line_text in &lines {
232        if y >= area.y + area.height {
233            break;
234        }
235        let line = Line::from(vec![
236            Span::raw(" "),
237            Span::styled(line_text.clone(), Style::default().fg(colors::OVERLAY)),
238        ]);
239        buf.set_line(area.x, y, &line, area.width);
240        y += 1;
241    }
242}
243
244/// Render the dashed separator + "loading…" indicator.
245fn render_loading_block(buf: &mut Buffer, area: Rect, mut y: u16) {
246    // Dashed separator (full width with 1-char padding on each side)
247    if y < area.y + area.height {
248        for x in (area.x + 1)..area.x + area.width.saturating_sub(1) {
249            buf.cell_mut((x, y))
250                .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
251        }
252        y += 1;
253    }
254
255    // Loading indicator
256    if y < area.y + area.height {
257        let line = Line::from(vec![
258            Span::raw(" "),
259            Span::styled(
260                "loading\u{2026}",
261                Style::default()
262                    .fg(colors::OVERLAY)
263                    .add_modifier(Modifier::ITALIC),
264            ),
265        ]);
266        buf.set_line(area.x, y, &line, area.width);
267    }
268}
269
270/// Render the dashed separator + error message (dimmed).
271fn render_error_block(buf: &mut Buffer, area: Rect, mut y: u16, message: &str) {
272    // Dashed separator (full width with 1-char padding on each side)
273    if y < area.y + area.height {
274        for x in (area.x + 1)..area.x + area.width.saturating_sub(1) {
275            buf.cell_mut((x, y))
276                .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
277        }
278        y += 1;
279    }
280
281    // Error message
282    if y < area.y + area.height {
283        let line = Line::from(vec![
284            Span::raw(" "),
285            Span::styled(
286                message.to_string(),
287                Style::default()
288                    .fg(colors::SURFACE)
289                    .add_modifier(Modifier::ITALIC),
290            ),
291        ]);
292        buf.set_line(area.x, y, &line, area.width);
293    }
294}
295
296/// Word-wrap text to fit within `max_width`, returning at most `max_lines` lines.
297fn wrap_text(text: &str, max_width: usize, max_lines: usize) -> Vec<String> {
298    if max_width == 0 || max_lines == 0 {
299        return Vec::new();
300    }
301
302    let mut lines = Vec::new();
303    let mut current = String::new();
304
305    for word in text.split_whitespace() {
306        let word_width = word.chars().count();
307        if current.is_empty() {
308            if word_width > max_width {
309                let truncated: String = word.chars().take(max_width.saturating_sub(1)).collect();
310                current = format!("{truncated}\u{2026}");
311                lines.push(current);
312                current = String::new();
313                if lines.len() >= max_lines {
314                    break;
315                }
316                continue;
317            }
318            current = word.to_string();
319        } else if current.chars().count() + 1 + word_width <= max_width {
320            current.push(' ');
321            current.push_str(word);
322        } else {
323            lines.push(current);
324            if lines.len() >= max_lines {
325                current = String::new();
326                break;
327            }
328            current = word.to_string();
329        }
330    }
331
332    if !current.is_empty() && lines.len() < max_lines {
333        lines.push(current);
334    }
335
336    lines
337}
338
339// ── Tests ──
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_wrap_short_text() {
347        let lines = wrap_text("hello world", 20, 3);
348        assert_eq!(lines, vec!["hello world"]);
349    }
350
351    #[test]
352    fn test_wrap_long_text() {
353        let lines = wrap_text("Adding OAuth login flow with Google provider", 25, 3);
354        assert_eq!(
355            lines,
356            vec!["Adding OAuth login flow", "with Google provider"]
357        );
358    }
359
360    #[test]
361    fn test_wrap_max_lines() {
362        let lines = wrap_text("one two three four five six seven eight", 10, 2);
363        assert_eq!(lines.len(), 2);
364    }
365
366    #[test]
367    fn test_wrap_empty() {
368        let lines = wrap_text("", 20, 3);
369        assert!(lines.is_empty());
370    }
371
372    #[test]
373    fn test_wrap_zero_width() {
374        let lines = wrap_text("hello", 0, 3);
375        assert!(lines.is_empty());
376    }
377
378    // ── Render output validation tests ──
379    //
380    // These tests render the SidebarWidget to a ratatui Buffer and assert
381    // that the actual output contains the expected text. This validates
382    // the full rendering pipeline — layout math, height guards, and content.
383
384    use crate::tmux::WindowInfo;
385
386    fn test_win(index: u32, name: &str) -> WindowInfo {
387        WindowInfo {
388            index,
389            name: name.to_string(),
390            is_active: false,
391            pane_path: format!("/project/{name}"),
392        }
393    }
394
395    /// Extract all text from a ratatui Buffer as a vector of strings (one per row).
396    fn buf_lines(buf: &Buffer, area: Rect) -> Vec<String> {
397        (area.y..area.y + area.height)
398            .map(|y| {
399                (area.x..area.x + area.width)
400                    .map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
401                    .collect::<String>()
402            })
403            .collect()
404    }
405
406    /// Check if any line in the buffer contains the given substring.
407    fn buf_contains(buf: &Buffer, area: Rect, needle: &str) -> bool {
408        buf_lines(buf, area)
409            .iter()
410            .any(|line| line.contains(needle))
411    }
412
413    #[test]
414    fn test_render_no_context() {
415        let area = Rect::new(0, 0, 40, 10);
416        let mut buf = Buffer::empty(area);
417
418        let windows = vec![test_win(1, "my-session")];
419        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
420
421        let widget = SidebarWidget {
422            windows: &windows,
423            states: &states,
424            selected: 0,
425            tick: 0,
426            context: None,
427            context_loading: false,
428            context_error: None,
429        };
430        widget.render(area, &mut buf);
431
432        // Should show session name but no context or loading
433        assert!(
434            buf_contains(&buf, area, "my-session"),
435            "should show session name"
436        );
437        assert!(
438            !buf_contains(&buf, area, "loading"),
439            "should not show loading"
440        );
441    }
442
443    #[test]
444    fn test_render_loading_state() {
445        let area = Rect::new(0, 0, 40, 10);
446        let mut buf = Buffer::empty(area);
447
448        let windows = vec![test_win(1, "my-session")];
449        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
450
451        let widget = SidebarWidget {
452            windows: &windows,
453            states: &states,
454            selected: 0,
455            tick: 0,
456            context: None,
457            context_loading: true,
458            context_error: None,
459        };
460        widget.render(area, &mut buf);
461
462        // Should show both session name and loading indicator
463        assert!(
464            buf_contains(&buf, area, "my-session"),
465            "should show session name"
466        );
467        assert!(
468            buf_contains(&buf, area, "loading\u{2026}"),
469            "should show loading indicator"
470        );
471    }
472
473    #[test]
474    fn test_render_context_text() {
475        let area = Rect::new(0, 0, 40, 10);
476        let mut buf = Buffer::empty(area);
477
478        let windows = vec![test_win(1, "my-session")];
479        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
480        let context_text = "Fixing auth bug in login flow";
481
482        let widget = SidebarWidget {
483            windows: &windows,
484            states: &states,
485            selected: 0,
486            tick: 0,
487            context: Some(context_text),
488            context_loading: false,
489            context_error: None,
490        };
491        widget.render(area, &mut buf);
492
493        // Should show session name and context text
494        assert!(
495            buf_contains(&buf, area, "my-session"),
496            "should show session name"
497        );
498        assert!(
499            buf_contains(&buf, area, "Fixing auth bug"),
500            "should show context text"
501        );
502        assert!(
503            !buf_contains(&buf, area, "loading"),
504            "should not show loading when context is available"
505        );
506    }
507
508    #[test]
509    fn test_render_context_not_swallowed_in_small_pane() {
510        // Minimum viable pane: header + separator + 1 session + separator + loading = 5 rows
511        let area = Rect::new(0, 0, 40, 5);
512        let mut buf = Buffer::empty(area);
513
514        let windows = vec![test_win(1, "sess")];
515        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
516
517        let widget = SidebarWidget {
518            windows: &windows,
519            states: &states,
520            selected: 0,
521            tick: 0,
522            context: None,
523            context_loading: true,
524            context_error: None,
525        };
526        widget.render(area, &mut buf);
527
528        // Loading should still render even in a small pane
529        assert!(
530            buf_contains(&buf, area, "loading\u{2026}"),
531            "loading should render in 5-row pane: {:?}",
532            buf_lines(&buf, area)
533        );
534    }
535
536    #[test]
537    fn test_render_context_swallowed_in_tiny_pane() {
538        // Pane too small: header(1) + sep(1) = body_start at row 2, height 3 → body has 1 row
539        // Context needs 2 rows (sep + loading), context_start = 3-2=1, body_start=2 → guard fails
540        let area = Rect::new(0, 0, 40, 3);
541        let mut buf = Buffer::empty(area);
542
543        let windows = vec![test_win(1, "sess")];
544        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
545
546        let widget = SidebarWidget {
547            windows: &windows,
548            states: &states,
549            selected: 0,
550            tick: 0,
551            context: None,
552            context_loading: true,
553            context_error: None,
554        };
555        widget.render(area, &mut buf);
556
557        // In a 3-row pane, context guard correctly prevents rendering
558        // (there's no space for it — this is expected behavior)
559        assert!(
560            !buf_contains(&buf, area, "loading\u{2026}"),
561            "loading should NOT render in 3-row pane (no space)"
562        );
563    }
564}