Skip to main content

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