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/// Default configuration values for SessionPicker
17pub mod defaults {
18    /// Current session marker
19    pub const CURRENT_MARKER: &str = "*";
20    /// No current marker (space)
21    pub const NO_MARKER: &str = " ";
22    /// Selection prefix for focused items
23    pub const SELECTION_PREFIX: &str = " > ";
24    /// No selection prefix
25    pub const NO_SELECTION_PREFIX: &str = "   ";
26    /// Block title
27    pub const TITLE: &str = " Sessions ";
28    /// Help text
29    pub const HELP_TEXT: &str = " Arrow keys to navigate | Enter to switch | Esc to cancel | * = current session";
30    /// No sessions message
31    pub const NO_SESSIONS_MESSAGE: &str = "   No sessions available";
32}
33
34/// Configuration for SessionPicker widget
35#[derive(Clone)]
36pub struct SessionPickerConfig {
37    /// Marker for current session
38    pub current_marker: String,
39    /// Marker for non-current sessions
40    pub no_marker: String,
41    /// Prefix for selected/focused item
42    pub selection_prefix: String,
43    /// Prefix for non-selected items
44    pub no_selection_prefix: String,
45    /// Block title
46    pub title: String,
47    /// Help text shown at bottom
48    pub help_text: String,
49    /// Message when no sessions available
50    pub no_sessions_message: String,
51}
52
53impl Default for SessionPickerConfig {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl SessionPickerConfig {
60    /// Create a new SessionPickerConfig with default values
61    pub fn new() -> Self {
62        Self {
63            current_marker: defaults::CURRENT_MARKER.to_string(),
64            no_marker: defaults::NO_MARKER.to_string(),
65            selection_prefix: defaults::SELECTION_PREFIX.to_string(),
66            no_selection_prefix: defaults::NO_SELECTION_PREFIX.to_string(),
67            title: defaults::TITLE.to_string(),
68            help_text: defaults::HELP_TEXT.to_string(),
69            no_sessions_message: defaults::NO_SESSIONS_MESSAGE.to_string(),
70        }
71    }
72
73    /// Set the current session marker
74    pub fn with_current_marker(mut self, marker: impl Into<String>) -> Self {
75        self.current_marker = marker.into();
76        self
77    }
78
79    /// Set the selection prefix
80    pub fn with_selection_prefix(mut self, prefix: impl Into<String>) -> Self {
81        self.selection_prefix = prefix.into();
82        self
83    }
84
85    /// Set the block title
86    pub fn with_title(mut self, title: impl Into<String>) -> Self {
87        self.title = title.into();
88        self
89    }
90
91    /// Set the help text
92    pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
93        self.help_text = text.into();
94        self
95    }
96
97    /// Set the no sessions message
98    pub fn with_no_sessions_message(mut self, message: impl Into<String>) -> Self {
99        self.no_sessions_message = message.into();
100        self
101    }
102}
103
104/// Information about a session for display purposes
105/// Information about an LLM session for display in the session picker.
106#[derive(Clone)]
107pub struct SessionInfo {
108    /// Session identifier.
109    pub id: i64,
110    /// Model name.
111    pub model: String,
112    /// Tokens used in context.
113    pub context_used: i64,
114    /// Maximum context limit.
115    pub context_limit: i32,
116    /// Session creation timestamp.
117    pub created_at: DateTime<Local>,
118}
119
120impl SessionInfo {
121    /// Create a new session info.
122    pub fn new(id: i64, model: String, context_limit: i32) -> Self {
123        Self {
124            id,
125            model,
126            context_used: 0,
127            context_limit,
128            created_at: Local::now(),
129        }
130    }
131}
132
133/// State for the session picker
134pub struct SessionPickerState {
135    /// Whether the picker is currently visible
136    pub active: bool,
137    /// Index of the currently selected session
138    pub selected_index: usize,
139    /// List of sessions to display
140    sessions: Vec<SessionInfo>,
141    /// Current active session ID (for marking with *)
142    current_session_id: i64,
143    /// Configuration for display customization
144    config: SessionPickerConfig,
145}
146
147impl SessionPickerState {
148    pub fn new() -> Self {
149        Self::with_config(SessionPickerConfig::new())
150    }
151
152    /// Create a new session picker with custom configuration
153    pub fn with_config(config: SessionPickerConfig) -> Self {
154        Self {
155            active: false,
156            selected_index: 0,
157            sessions: Vec::new(),
158            current_session_id: 0,
159            config,
160        }
161    }
162
163    /// Get the current configuration
164    pub fn config(&self) -> &SessionPickerConfig {
165        &self.config
166    }
167
168    /// Set a new configuration
169    pub fn set_config(&mut self, config: SessionPickerConfig) {
170        self.config = config;
171    }
172
173    /// Activate the picker with the given sessions
174    pub fn activate(&mut self, sessions: Vec<SessionInfo>, current_session_id: i64) {
175        self.active = true;
176        self.sessions = sessions;
177        self.current_session_id = current_session_id;
178
179        // Find and select current session in list
180        self.selected_index = self
181            .sessions
182            .iter()
183            .position(|s| s.id == current_session_id)
184            .unwrap_or(0);
185    }
186
187    /// Deactivate without switching (cancel)
188    pub fn cancel(&mut self) {
189        self.active = false;
190    }
191
192    /// Deactivate after switching (confirm)
193    pub fn confirm(&mut self) {
194        self.active = false;
195    }
196
197    /// Move selection up
198    pub fn select_previous(&mut self) {
199        if self.sessions.is_empty() {
200            return;
201        }
202        if self.selected_index == 0 {
203            self.selected_index = self.sessions.len() - 1;
204        } else {
205            self.selected_index -= 1;
206        }
207    }
208
209    /// Move selection down
210    pub fn select_next(&mut self) {
211        if self.sessions.is_empty() {
212            return;
213        }
214        self.selected_index = (self.selected_index + 1) % self.sessions.len();
215    }
216
217    /// Get the currently selected session ID
218    pub fn selected_session_id(&self) -> Option<i64> {
219        self.sessions.get(self.selected_index).map(|s| s.id)
220    }
221
222    /// Get the currently selected session info
223    pub fn selected_session(&self) -> Option<&SessionInfo> {
224        self.sessions.get(self.selected_index)
225    }
226}
227
228impl Default for SessionPickerState {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234// --- Widget trait implementation ---
235
236use std::any::Any;
237use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
238use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
239
240/// Result of handling a key event in the session picker
241#[derive(Debug, Clone, PartialEq)]
242pub enum SessionKeyAction {
243    /// No action taken
244    None,
245    /// Session selected (includes session ID)
246    Selected(i64),
247    /// Picker was cancelled
248    Cancelled,
249}
250
251impl SessionPickerState {
252    /// Handle a key event
253    pub fn process_key(&mut self, key: KeyEvent) -> SessionKeyAction {
254        if !self.active {
255            return SessionKeyAction::None;
256        }
257
258        match key.code {
259            KeyCode::Up => {
260                self.select_previous();
261                SessionKeyAction::None
262            }
263            KeyCode::Down => {
264                self.select_next();
265                SessionKeyAction::None
266            }
267            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
268                self.select_previous();
269                SessionKeyAction::None
270            }
271            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
272                self.select_next();
273                SessionKeyAction::None
274            }
275            KeyCode::Enter => {
276                if let Some(session_id) = self.selected_session_id() {
277                    self.confirm();
278                    SessionKeyAction::Selected(session_id)
279                } else {
280                    SessionKeyAction::None
281                }
282            }
283            KeyCode::Esc => {
284                self.cancel();
285                SessionKeyAction::Cancelled
286            }
287            _ => SessionKeyAction::None,
288        }
289    }
290}
291
292impl Widget for SessionPickerState {
293    fn id(&self) -> &'static str {
294        widget_ids::SESSION_PICKER
295    }
296
297    fn priority(&self) -> u8 {
298        250 // Very high priority - overlay
299    }
300
301    fn is_active(&self) -> bool {
302        self.active
303    }
304
305    fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
306        if !self.active {
307            return WidgetKeyResult::NotHandled;
308        }
309
310        // Use NavigationHelper for key bindings
311        if ctx.nav.is_move_up(&key) {
312            self.select_previous();
313            return WidgetKeyResult::Handled;
314        }
315        if ctx.nav.is_move_down(&key) {
316            self.select_next();
317            return WidgetKeyResult::Handled;
318        }
319        if ctx.nav.is_select(&key) {
320            if let Some(session_id) = self.selected_session_id() {
321                self.confirm();
322                return WidgetKeyResult::Action(WidgetAction::SwitchSession { session_id });
323            }
324            return WidgetKeyResult::Handled;
325        }
326        if ctx.nav.is_cancel(&key) {
327            self.cancel();
328            return WidgetKeyResult::Action(WidgetAction::Close);
329        }
330
331        // Other keys are ignored
332        WidgetKeyResult::Handled
333    }
334
335    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
336        render_session_picker(self, frame, area, theme);
337    }
338
339    fn required_height(&self, _available: u16) -> u16 {
340        0 // Overlay widget - doesn't need dedicated height
341    }
342
343    fn blocks_input(&self) -> bool {
344        self.active
345    }
346
347    fn is_overlay(&self) -> bool {
348        true
349    }
350
351    fn as_any(&self) -> &dyn Any {
352        self
353    }
354
355    fn as_any_mut(&mut self) -> &mut dyn Any {
356        self
357    }
358
359    fn into_any(self: Box<Self>) -> Box<dyn Any> {
360        self
361    }
362}
363
364/// Render the session picker
365pub fn render_session_picker(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
366    if !state.active {
367        return;
368    }
369
370    // Clear the area first
371    frame.render_widget(Clear, area);
372
373    // Split into main area and bottom help bar
374    let main_chunks =
375        Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).split(area);
376
377    // Session list (single pane with all info per line)
378    render_session_list(state, frame, main_chunks[0], theme);
379
380    // Bottom help bar
381    render_help_bar(state, frame, main_chunks[1], theme);
382}
383
384/// Render the session list with all info on each line
385fn render_session_list(
386    state: &SessionPickerState,
387    frame: &mut Frame,
388    area: Rect,
389    theme: &Theme,
390) {
391    let mut lines = Vec::new();
392
393    // Header
394    lines.push(Line::from(""));
395
396    if state.sessions.is_empty() {
397        lines.push(Line::from(Span::styled(
398            state.config.no_sessions_message.clone(),
399            theme.text(),
400        )));
401    } else {
402        // Calculate max model name length for alignment
403        let max_model_len = state
404            .sessions
405            .iter()
406            .map(|s| s.model.len())
407            .max()
408            .unwrap_or(0);
409
410        for (idx, session) in state.sessions.iter().enumerate() {
411            let is_selected = idx == state.selected_index;
412            let is_current = session.id == state.current_session_id;
413
414            let marker = if is_current { &state.config.current_marker } else { &state.config.no_marker };
415            let prefix = if is_selected { &state.config.selection_prefix } else { &state.config.no_selection_prefix };
416            let context_str = format_context(session.context_used, session.context_limit);
417            let time_str = session.created_at.format("%H:%M:%S").to_string();
418
419            // Format with labels and aligned model name
420            let text = format!(
421                "{}{} Session {} | Model: {:<width$} | Context: {} | Created: {}",
422                prefix,
423                marker,
424                session.id,
425                session.model,
426                context_str,
427                time_str,
428                width = max_model_len
429            );
430
431            let style = if is_selected {
432                theme.popup_selected_bg().patch(theme.popup_item_selected())
433            } else {
434                theme.popup_item()
435            };
436
437            // Pad to full width for selection highlight
438            let inner_width = area.width.saturating_sub(2) as usize;
439            let padded = format!("{:<width$}", text, width = inner_width);
440            lines.push(Line::from(Span::styled(padded, style)));
441        }
442    }
443
444    let block = Block::default()
445        .title(state.config.title.clone())
446        .borders(Borders::ALL)
447        .border_style(theme.popup_border());
448
449    let list = Paragraph::new(lines)
450        .block(block)
451        .style(theme.background().patch(theme.text()));
452
453    frame.render_widget(list, area);
454}
455
456/// Render the help bar at the bottom
457fn render_help_bar(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
458    let help = Paragraph::new(state.config.help_text.clone()).style(theme.status_help());
459    frame.render_widget(help, area);
460}
461
462/// Format context usage for display (e.g., "45.2K / 200K")
463fn format_context(used: i64, limit: i32) -> String {
464    let used_str = format_tokens(used);
465    let limit_str = format_tokens(limit as i64);
466    format!("{} / {}", used_str, limit_str)
467}
468
469/// Format token counts for display (e.g., "4.3K", "200K", "850")
470fn format_tokens(tokens: i64) -> String {
471    if tokens >= 100_000 {
472        format!("{}K", tokens / 1000)
473    } else if tokens >= 1000 {
474        format!("{:.1}K", tokens as f64 / 1000.0)
475    } else {
476        format!("{}", tokens)
477    }
478}