mermaid_cli/session/
selector.rs

1use anyhow::Result;
2use crossterm::{
3    event::{self, Event, KeyCode},
4    execute,
5    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
6};
7use ratatui::{
8    backend::CrosstermBackend,
9    layout::{Constraint, Direction, Layout},
10    style::{Color, Modifier, Style},
11    text::{Line, Span},
12    widgets::{Block, Borders, List, ListItem, Paragraph},
13    Frame, Terminal,
14};
15use std::io;
16
17use super::conversation::ConversationHistory;
18
19/// Show a selection UI for choosing a conversation to resume
20pub fn select_conversation(
21    conversations: Vec<ConversationHistory>,
22) -> Result<Option<ConversationHistory>> {
23    if conversations.is_empty() {
24        println!("No previous conversations found in this directory.");
25        return Ok(None);
26    }
27
28    // If there's only one conversation, return it directly
29    if conversations.len() == 1 {
30        return Ok(conversations.into_iter().next());
31    }
32
33    // Setup terminal
34    enable_raw_mode()?;
35    let mut stdout = io::stdout();
36    execute!(stdout, EnterAlternateScreen)?;
37    let backend = CrosstermBackend::new(stdout);
38    let mut terminal = Terminal::new(backend)?;
39
40    // Create app state
41    let mut app = ConversationSelector {
42        conversations,
43        selected: 0,
44    };
45
46    // Run the UI loop
47    let result = run_selector(&mut terminal, &mut app);
48
49    // Restore terminal
50    disable_raw_mode()?;
51    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
52    terminal.show_cursor()?;
53
54    result
55}
56
57struct ConversationSelector {
58    conversations: Vec<ConversationHistory>,
59    selected: usize,
60}
61
62fn run_selector(
63    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
64    app: &mut ConversationSelector,
65) -> Result<Option<ConversationHistory>> {
66    loop {
67        terminal.draw(|f| render_selector(f, app))?;
68
69        if let Event::Key(key) = event::read()? {
70            match key.code {
71                KeyCode::Char('q') | KeyCode::Esc => {
72                    return Ok(None);
73                },
74                KeyCode::Enter => {
75                    let selected = app.conversations[app.selected].clone();
76                    return Ok(Some(selected));
77                },
78                KeyCode::Down | KeyCode::Char('j') => {
79                    if app.selected < app.conversations.len() - 1 {
80                        app.selected += 1;
81                    }
82                },
83                KeyCode::Up | KeyCode::Char('k') => {
84                    if app.selected > 0 {
85                        app.selected -= 1;
86                    }
87                },
88                KeyCode::Home => {
89                    app.selected = 0;
90                },
91                KeyCode::End => {
92                    app.selected = app.conversations.len() - 1;
93                },
94                _ => {},
95            }
96        }
97    }
98}
99
100fn render_selector(f: &mut Frame, app: &ConversationSelector) {
101    let chunks = Layout::default()
102        .direction(Direction::Vertical)
103        .constraints([
104            Constraint::Length(3),
105            Constraint::Min(5),
106            Constraint::Length(3),
107        ])
108        .split(f.area());
109
110    // Title
111    let title = Paragraph::new("Select a conversation to resume")
112        .style(
113            Style::default()
114                .fg(Color::Cyan)
115                .add_modifier(Modifier::BOLD),
116        )
117        .block(
118            Block::default()
119                .borders(Borders::ALL)
120                .title(" Mermaid - Resume Session "),
121        );
122    f.render_widget(title, chunks[0]);
123
124    // Conversation list
125    let items: Vec<ListItem> = app
126        .conversations
127        .iter()
128        .enumerate()
129        .map(|(i, conv)| {
130            let style = if i == app.selected {
131                Style::default()
132                    .bg(Color::Blue)
133                    .fg(Color::White)
134                    .add_modifier(Modifier::BOLD)
135            } else {
136                Style::default()
137            };
138
139            let content = vec![
140                Line::from(vec![Span::styled(&conv.title, style)]),
141                Line::from(vec![Span::styled(
142                    format!(
143                        "  {} | {} messages | Model: {}",
144                        conv.updated_at.format("%Y-%m-%d %H:%M"),
145                        conv.messages.len(),
146                        conv.model_name
147                    ),
148                    style.fg(Color::Gray),
149                )]),
150            ];
151
152            ListItem::new(content)
153        })
154        .collect();
155
156    let list = List::new(items)
157        .block(
158            Block::default()
159                .borders(Borders::ALL)
160                .title(" Previous Conversations "),
161        )
162        .highlight_style(Style::default())
163        .highlight_symbol("");
164
165    f.render_widget(list, chunks[1]);
166
167    // Help text
168    let help = vec![Line::from(vec![
169        Span::raw("Up/k: Up  Down/j: Down  "),
170        Span::styled("Enter", Style::default().fg(Color::Green)),
171        Span::raw(": Select  "),
172        Span::styled("q/Esc", Style::default().fg(Color::Red)),
173        Span::raw(": Cancel"),
174    ])];
175    let help_widget = Paragraph::new(help)
176        .style(Style::default().fg(Color::DarkGray))
177        .block(Block::default().borders(Borders::ALL));
178    f.render_widget(help_widget, chunks[2]);
179}