Skip to main content

mermaid_cli/render/widgets/
conversation_list.rs

1//! `/load` picker — renders the bottom zone when
2//! `UiMode::ConversationList` is active.
3//!
4//! Same visual shape as the slash palette (a bordered pane with an
5//! arrow-selectable list) but with richer per-row content: title,
6//! message count, updated-at timestamp.
7
8use ratatui::buffer::Buffer;
9use ratatui::layout::Rect;
10use ratatui::style::{Color, Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Block, Borders, Paragraph, Widget};
13
14use super::truncate_to_cells;
15use crate::domain::ConversationSummary;
16use crate::render::theme::Theme;
17
18pub struct ConversationListWidget<'a> {
19    pub theme: &'a Theme,
20    pub candidates: &'a [ConversationSummary],
21    pub cursor: usize,
22}
23
24impl<'a> Widget for ConversationListWidget<'a> {
25    fn render(self, area: Rect, buf: &mut Buffer) {
26        let title = if self.candidates.is_empty() {
27            "Load conversation — (none found)"
28        } else {
29            "Load conversation — ↑↓ navigate · Enter select · Esc cancel"
30        };
31        let block = Block::default()
32            .borders(Borders::ALL)
33            .title(title)
34            .border_style(Style::default().fg(self.theme.colors.border.to_color()));
35
36        // Reserve room for borders; show up to `visible` rows.
37        let inner_height = area.height.saturating_sub(2) as usize;
38        let visible = inner_height.min(10);
39        let start = if self.cursor >= visible {
40            self.cursor + 1 - visible
41        } else {
42            0
43        };
44
45        let rows: Vec<Line<'_>> = self
46            .candidates
47            .iter()
48            .enumerate()
49            .skip(start)
50            .take(visible)
51            .map(|(i, summary)| {
52                let highlighted = i == self.cursor;
53                let prefix = if highlighted { " > " } else { "   " };
54                let row_style = if highlighted {
55                    Style::default()
56                        .bg(self.theme.colors.text_disabled.to_color())
57                        .add_modifier(Modifier::BOLD)
58                } else {
59                    Style::default()
60                };
61                let title = truncate_to_cells(&summary.title, 48);
62                let meta = format!(
63                    "  ({} msg · {})",
64                    summary.message_count,
65                    short_timestamp(&summary.updated_at)
66                );
67                Line::from(vec![
68                    Span::raw(prefix),
69                    Span::styled(title, row_style.fg(Color::White)),
70                    Span::styled(
71                        meta,
72                        row_style.fg(self.theme.colors.text_disabled.to_color()),
73                    ),
74                ])
75            })
76            .collect();
77
78        Paragraph::new(rows).block(block).render(area, buf);
79    }
80}
81
82/// `2026-04-21T14:30:12-04:00` → `2026-04-21 14:30`. If parsing fails
83/// for any reason, returns the original string.
84fn short_timestamp(rfc3339: &str) -> String {
85    // Extract the `YYYY-MM-DDTHH:MM` portion (16 ASCII bytes) and swap the
86    // 'T' for a space. Clamp to a char boundary so a malformed value with a
87    // multi-byte sequence straddling byte 16 can't panic the slice (#102).
88    if rfc3339.len() >= 16 {
89        let cut = rfc3339.floor_char_boundary(16);
90        let mut s = rfc3339[..cut].to_string();
91        if let Some(t_pos) = s.find('T') {
92            s.replace_range(t_pos..t_pos + 1, " ");
93        }
94        s
95    } else {
96        rfc3339.to_string()
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn short_timestamp_formats_rfc3339() {
106        assert_eq!(
107            short_timestamp("2026-04-21T14:30:12-04:00"),
108            "2026-04-21 14:30"
109        );
110    }
111
112    #[test]
113    fn short_timestamp_passes_through_short_input() {
114        assert_eq!(short_timestamp("2026"), "2026");
115        assert_eq!(short_timestamp(""), "");
116    }
117
118    #[test]
119    fn short_timestamp_does_not_panic_on_multibyte_boundary() {
120        // "2026-04-21T14:3" is 15 bytes; then "好" (3 bytes) straddles byte 16.
121        // floor_char_boundary(16) backs up to byte 15 instead of panicking (#102).
122        assert_eq!(short_timestamp("2026-04-21T14:3好0:12"), "2026-04-21 14:3");
123    }
124}