mermaid_cli/render/widgets/
conversation_list.rs1use 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 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
82fn short_timestamp(rfc3339: &str) -> String {
85 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 assert_eq!(short_timestamp("2026-04-21T14:3好0:12"), "2026-04-21 14:3");
123 }
124}