agent_core/tui/widgets/
session_picker.rs

1//! Session picker widget for viewing and switching between sessions
2//!
3//! Full-screen overlay that displays available sessions on the left
4//! and session details on the right.
5
6use chrono::{DateTime, Local};
7use ratatui::{
8    layout::{Constraint, Layout, Rect},
9    text::{Line, Span},
10    widgets::{Block, Borders, Clear, Paragraph},
11    Frame,
12};
13
14use crate::tui::themes::Theme;
15
16/// Information about a session for display purposes
17#[derive(Clone)]
18pub struct SessionInfo {
19    pub id: i64,
20    pub model: String,
21    pub context_used: i64,
22    pub context_limit: i32,
23    pub created_at: DateTime<Local>,
24}
25
26impl SessionInfo {
27    pub fn new(id: i64, model: String, context_limit: i32) -> Self {
28        Self {
29            id,
30            model,
31            context_used: 0,
32            context_limit,
33            created_at: Local::now(),
34        }
35    }
36}
37
38/// State for the session picker
39pub struct SessionPickerState {
40    /// Whether the picker is currently visible
41    pub active: bool,
42    /// Index of the currently selected session
43    pub selected_index: usize,
44    /// List of sessions to display
45    sessions: Vec<SessionInfo>,
46    /// Current active session ID (for marking with *)
47    current_session_id: i64,
48}
49
50impl SessionPickerState {
51    pub fn new() -> Self {
52        Self {
53            active: false,
54            selected_index: 0,
55            sessions: Vec::new(),
56            current_session_id: 0,
57        }
58    }
59
60    /// Activate the picker with the given sessions
61    pub fn activate(&mut self, sessions: Vec<SessionInfo>, current_session_id: i64) {
62        self.active = true;
63        self.sessions = sessions;
64        self.current_session_id = current_session_id;
65
66        // Find and select current session in list
67        self.selected_index = self
68            .sessions
69            .iter()
70            .position(|s| s.id == current_session_id)
71            .unwrap_or(0);
72    }
73
74    /// Deactivate without switching (cancel)
75    pub fn cancel(&mut self) {
76        self.active = false;
77    }
78
79    /// Deactivate after switching (confirm)
80    pub fn confirm(&mut self) {
81        self.active = false;
82    }
83
84    /// Move selection up
85    pub fn select_previous(&mut self) {
86        if self.sessions.is_empty() {
87            return;
88        }
89        if self.selected_index == 0 {
90            self.selected_index = self.sessions.len() - 1;
91        } else {
92            self.selected_index -= 1;
93        }
94    }
95
96    /// Move selection down
97    pub fn select_next(&mut self) {
98        if self.sessions.is_empty() {
99            return;
100        }
101        self.selected_index = (self.selected_index + 1) % self.sessions.len();
102    }
103
104    /// Get the currently selected session ID
105    pub fn selected_session_id(&self) -> Option<i64> {
106        self.sessions.get(self.selected_index).map(|s| s.id)
107    }
108
109    /// Get the currently selected session info
110    pub fn selected_session(&self) -> Option<&SessionInfo> {
111        self.sessions.get(self.selected_index)
112    }
113}
114
115impl Default for SessionPickerState {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121// --- Widget trait implementation ---
122
123use std::any::Any;
124use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
125use super::{widget_ids, Widget, WidgetAction, WidgetKeyResult};
126
127/// Result of handling a key event in the session picker
128#[derive(Debug, Clone, PartialEq)]
129pub enum SessionKeyAction {
130    /// No action taken
131    None,
132    /// Session selected (includes session ID)
133    Selected(i64),
134    /// Picker was cancelled
135    Cancelled,
136}
137
138impl SessionPickerState {
139    /// Handle a key event
140    pub fn process_key(&mut self, key: KeyEvent) -> SessionKeyAction {
141        if !self.active {
142            return SessionKeyAction::None;
143        }
144
145        match key.code {
146            KeyCode::Up => {
147                self.select_previous();
148                SessionKeyAction::None
149            }
150            KeyCode::Down => {
151                self.select_next();
152                SessionKeyAction::None
153            }
154            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
155                self.select_previous();
156                SessionKeyAction::None
157            }
158            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
159                self.select_next();
160                SessionKeyAction::None
161            }
162            KeyCode::Enter => {
163                if let Some(session_id) = self.selected_session_id() {
164                    self.confirm();
165                    SessionKeyAction::Selected(session_id)
166                } else {
167                    SessionKeyAction::None
168                }
169            }
170            KeyCode::Esc => {
171                self.cancel();
172                SessionKeyAction::Cancelled
173            }
174            _ => SessionKeyAction::None,
175        }
176    }
177}
178
179impl Widget for SessionPickerState {
180    fn id(&self) -> &'static str {
181        widget_ids::SESSION_PICKER
182    }
183
184    fn priority(&self) -> u8 {
185        250 // Very high priority - overlay
186    }
187
188    fn is_active(&self) -> bool {
189        self.active
190    }
191
192    fn handle_key(&mut self, key: KeyEvent, _theme: &Theme) -> WidgetKeyResult {
193        if !self.active {
194            return WidgetKeyResult::NotHandled;
195        }
196
197        match self.process_key(key) {
198            SessionKeyAction::Selected(session_id) => {
199                WidgetKeyResult::Action(WidgetAction::SwitchSession { session_id })
200            }
201            SessionKeyAction::Cancelled => WidgetKeyResult::Action(WidgetAction::Close),
202            SessionKeyAction::None => WidgetKeyResult::Handled,
203        }
204    }
205
206    fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
207        render_session_picker(self, frame, area, theme);
208    }
209
210    fn required_height(&self, _available: u16) -> u16 {
211        0 // Overlay widget - doesn't need dedicated height
212    }
213
214    fn blocks_input(&self) -> bool {
215        self.active
216    }
217
218    fn is_overlay(&self) -> bool {
219        true
220    }
221
222    fn as_any(&self) -> &dyn Any {
223        self
224    }
225
226    fn as_any_mut(&mut self) -> &mut dyn Any {
227        self
228    }
229
230    fn into_any(self: Box<Self>) -> Box<dyn Any> {
231        self
232    }
233}
234
235/// Render the session picker
236pub fn render_session_picker(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
237    if !state.active {
238        return;
239    }
240
241    // Clear the area first
242    frame.render_widget(Clear, area);
243
244    // Split into main area and bottom help bar
245    let main_chunks =
246        Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).split(area);
247
248    // Session list (single pane with all info per line)
249    render_session_list(state, frame, main_chunks[0], theme);
250
251    // Bottom help bar
252    render_help_bar(frame, main_chunks[1], theme);
253}
254
255/// Render the session list with all info on each line
256fn render_session_list(
257    state: &SessionPickerState,
258    frame: &mut Frame,
259    area: Rect,
260    theme: &Theme,
261) {
262    let mut lines = Vec::new();
263
264    // Header
265    lines.push(Line::from(""));
266
267    if state.sessions.is_empty() {
268        lines.push(Line::from(Span::styled(
269            "   No sessions available",
270            theme.text(),
271        )));
272    } else {
273        // Calculate max model name length for alignment
274        let max_model_len = state
275            .sessions
276            .iter()
277            .map(|s| s.model.len())
278            .max()
279            .unwrap_or(0);
280
281        for (idx, session) in state.sessions.iter().enumerate() {
282            let is_selected = idx == state.selected_index;
283            let is_current = session.id == state.current_session_id;
284
285            let marker = if is_current { "*" } else { " " };
286            let prefix = if is_selected { " > " } else { "   " };
287            let context_str = format_context(session.context_used, session.context_limit);
288            let time_str = session.created_at.format("%H:%M:%S").to_string();
289
290            // Format with labels and aligned model name
291            let text = format!(
292                "{}{} Session {} | Model: {:<width$} | Context: {} | Created: {}",
293                prefix,
294                marker,
295                session.id,
296                session.model,
297                context_str,
298                time_str,
299                width = max_model_len
300            );
301
302            let style = if is_selected {
303                theme.popup_selected_bg().patch(theme.popup_item_selected())
304            } else {
305                theme.popup_item()
306            };
307
308            // Pad to full width for selection highlight
309            let inner_width = area.width.saturating_sub(2) as usize;
310            let padded = format!("{:<width$}", text, width = inner_width);
311            lines.push(Line::from(Span::styled(padded, style)));
312        }
313    }
314
315    let block = Block::default()
316        .title(" Sessions ")
317        .borders(Borders::ALL)
318        .border_style(theme.popup_border());
319
320    let list = Paragraph::new(lines)
321        .block(block)
322        .style(theme.background().patch(theme.text()));
323
324    frame.render_widget(list, area);
325}
326
327/// Render the help bar at the bottom
328fn render_help_bar(frame: &mut Frame, area: Rect, theme: &Theme) {
329    let help_text =
330        " Arrow keys to navigate | Enter to switch | Esc to cancel | * = current session";
331    let help = Paragraph::new(help_text).style(theme.status_help());
332    frame.render_widget(help, area);
333}
334
335/// Format context usage for display (e.g., "45.2K / 200K")
336fn format_context(used: i64, limit: i32) -> String {
337    let used_str = format_tokens(used);
338    let limit_str = format_tokens(limit as i64);
339    format!("{} / {}", used_str, limit_str)
340}
341
342/// Format token counts for display (e.g., "4.3K", "200K", "850")
343fn format_tokens(tokens: i64) -> String {
344    if tokens >= 100_000 {
345        format!("{}K", tokens / 1000)
346    } else if tokens >= 1000 {
347        format!("{:.1}K", tokens as f64 / 1000.0)
348    } else {
349        format!("{}", tokens)
350    }
351}