mermaid_cli/render/widgets/
conversation_list.rs1use ratatui::buffer::Buffer;
9use ratatui::layout::Rect;
10use ratatui::style::{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(
70 title,
71 row_style.fg(self.theme.colors.text_primary.to_color()),
72 ),
73 Span::styled(
74 meta,
75 row_style.fg(self.theme.colors.text_disabled.to_color()),
76 ),
77 ])
78 })
79 .collect();
80
81 Paragraph::new(rows).block(block).render(area, buf);
82 }
83}
84
85fn short_timestamp(rfc3339: &str) -> String {
88 if rfc3339.len() >= 16 {
92 let cut = rfc3339.floor_char_boundary(16);
93 let mut s = rfc3339[..cut].to_string();
94 if let Some(t_pos) = s.find('T') {
95 s.replace_range(t_pos..t_pos + 1, " ");
96 }
97 s
98 } else {
99 rfc3339.to_string()
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn short_timestamp_formats_rfc3339() {
109 assert_eq!(
110 short_timestamp("2026-04-21T14:30:12-04:00"),
111 "2026-04-21 14:30"
112 );
113 }
114
115 #[test]
116 fn short_timestamp_passes_through_short_input() {
117 assert_eq!(short_timestamp("2026"), "2026");
118 assert_eq!(short_timestamp(""), "");
119 }
120
121 #[test]
122 fn short_timestamp_does_not_panic_on_multibyte_boundary() {
123 assert_eq!(short_timestamp("2026-04-21T14:3好0:12"), "2026-04-21 14:3");
126 }
127}