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