agcodex_tui/widgets/
session_switcher.rs

1//! Quick session switcher widget for AGCodex TUI
2//! Provides Alt+[1-9] quick switching and session tabs at the bottom
3
4use agcodex_persistence::types::OperatingMode;
5use agcodex_persistence::types::SessionMetadata;
6use chrono::DateTime;
7use chrono::Local;
8use ratatui::buffer::Buffer;
9use ratatui::crossterm::event::KeyCode;
10use ratatui::crossterm::event::KeyEvent;
11use ratatui::crossterm::event::KeyModifiers;
12use ratatui::layout::Constraint;
13use ratatui::layout::Direction;
14use ratatui::layout::Layout;
15use ratatui::layout::Rect;
16use ratatui::style::Color;
17use ratatui::style::Modifier;
18use ratatui::style::Style;
19use ratatui::text::Line;
20use ratatui::text::Span;
21use ratatui::widgets::Block;
22use ratatui::widgets::BorderType;
23use ratatui::widgets::Borders;
24use ratatui::widgets::Tabs;
25use ratatui::widgets::Widget;
26use ratatui::widgets::WidgetRef;
27use std::collections::HashMap;
28use uuid::Uuid;
29
30/// Session entry for the switcher
31#[derive(Debug, Clone)]
32pub struct SessionEntry {
33    pub id: Uuid,
34    pub title: String,
35    pub mode: OperatingMode,
36    pub is_modified: bool,
37    pub last_accessed: DateTime<Local>,
38    pub message_count: usize,
39    pub shortcut_key: Option<u8>, // 1-9 for Alt+[1-9] shortcuts
40}
41
42impl SessionEntry {
43    pub fn from_metadata(metadata: SessionMetadata, is_modified: bool) -> Self {
44        let title = if metadata.title.is_empty() {
45            format!("Session {}", &metadata.id.to_string()[0..8])
46        } else {
47            metadata.title.clone()
48        };
49
50        Self {
51            id: metadata.id,
52            title,
53            mode: metadata.current_mode,
54            is_modified,
55            last_accessed: metadata.last_accessed.into(),
56            message_count: metadata.message_count,
57            shortcut_key: None,
58        }
59    }
60
61    /// Get display string for the tab
62    pub fn tab_display(&self) -> String {
63        let mode_icon = match self.mode {
64            OperatingMode::Plan => "📋",
65            OperatingMode::Build => "🔨",
66            OperatingMode::Review => "🔍",
67        };
68
69        let modified_indicator = if self.is_modified { "*" } else { "" };
70
71        if let Some(key) = self.shortcut_key {
72            format!("{} {} {}{}", key, mode_icon, self.title, modified_indicator)
73        } else {
74            format!("{} {}{}", mode_icon, self.title, modified_indicator)
75        }
76    }
77
78    /// Get shortened title for compact display
79    pub fn short_title(&self, max_len: usize) -> String {
80        if self.title.len() <= max_len {
81            self.title.clone()
82        } else {
83            format!("{}…", &self.title[..max_len.saturating_sub(1)])
84        }
85    }
86}
87
88/// State for the session switcher
89#[derive(Debug)]
90pub struct SessionSwitcherState {
91    /// Active sessions (up to 9 for shortcuts)
92    pub sessions: Vec<SessionEntry>,
93    /// Currently active session ID
94    pub active_session_id: Option<Uuid>,
95    /// Selected index in the switcher (for Ctrl+Tab navigation)
96    pub selected_index: usize,
97    /// Whether the switcher is visible
98    pub is_visible: bool,
99    /// Maximum number of sessions with shortcuts
100    pub max_shortcuts: usize,
101    /// Modified sessions tracking
102    pub modified_sessions: HashMap<Uuid, bool>,
103    /// Compact mode for small terminals
104    pub compact_mode: bool,
105}
106
107impl SessionSwitcherState {
108    pub fn new() -> Self {
109        Self {
110            sessions: Vec::new(),
111            active_session_id: None,
112            selected_index: 0,
113            is_visible: true,
114            max_shortcuts: 9,
115            modified_sessions: HashMap::new(),
116            compact_mode: false,
117        }
118    }
119
120    /// Add or update a session
121    pub fn add_session(&mut self, metadata: SessionMetadata, make_active: bool) {
122        let is_modified = self
123            .modified_sessions
124            .get(&metadata.id)
125            .copied()
126            .unwrap_or(false);
127        let mut entry = SessionEntry::from_metadata(metadata, is_modified);
128        let entry_id = entry.id;
129
130        // Check if session already exists
131        if let Some(pos) = self.sessions.iter().position(|s| s.id == entry.id) {
132            // Update existing session
133            self.sessions[pos] = entry;
134        } else {
135            // Add new session
136            if self.sessions.len() < self.max_shortcuts {
137                entry.shortcut_key = Some((self.sessions.len() + 1) as u8);
138            }
139            self.sessions.push(entry);
140        }
141
142        if make_active {
143            self.active_session_id = Some(entry_id);
144            self.selected_index = self
145                .sessions
146                .iter()
147                .position(|s| s.id == entry_id)
148                .unwrap_or(0);
149        }
150
151        // Sort by last accessed time (most recent first)
152        self.sessions
153            .sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
154
155        // Reassign shortcut keys
156        self.reassign_shortcuts();
157    }
158
159    /// Remove a session
160    pub fn remove_session(&mut self, id: Uuid) {
161        self.sessions.retain(|s| s.id != id);
162        self.modified_sessions.remove(&id);
163
164        // If removed session was active, switch to first available
165        if self.active_session_id == Some(id) {
166            self.active_session_id = self.sessions.first().map(|s| s.id);
167            self.selected_index = 0;
168        }
169
170        self.reassign_shortcuts();
171    }
172
173    /// Mark session as modified
174    pub fn mark_modified(&mut self, id: Uuid, modified: bool) {
175        self.modified_sessions.insert(id, modified);
176
177        if let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) {
178            session.is_modified = modified;
179        }
180    }
181
182    /// Switch to session by shortcut key (1-9)
183    pub fn switch_by_shortcut(&mut self, key: u8) -> Option<Uuid> {
184        if !(1..=9).contains(&key) {
185            return None;
186        }
187
188        self.sessions
189            .iter()
190            .find(|s| s.shortcut_key == Some(key))
191            .map(|s| {
192                self.active_session_id = Some(s.id);
193                self.selected_index = self
194                    .sessions
195                    .iter()
196                    .position(|sess| sess.id == s.id)
197                    .unwrap_or(0);
198                s.id
199            })
200    }
201
202    /// Switch to next session (Ctrl+Tab)
203    pub fn switch_next(&mut self) -> Option<Uuid> {
204        if self.sessions.is_empty() {
205            return None;
206        }
207
208        self.selected_index = (self.selected_index + 1) % self.sessions.len();
209        let session = &self.sessions[self.selected_index];
210        self.active_session_id = Some(session.id);
211        Some(session.id)
212    }
213
214    /// Switch to previous session (Ctrl+Shift+Tab)
215    pub fn switch_previous(&mut self) -> Option<Uuid> {
216        if self.sessions.is_empty() {
217            return None;
218        }
219
220        self.selected_index = if self.selected_index == 0 {
221            self.sessions.len() - 1
222        } else {
223            self.selected_index - 1
224        };
225
226        let session = &self.sessions[self.selected_index];
227        self.active_session_id = Some(session.id);
228        Some(session.id)
229    }
230
231    /// Reassign shortcut keys after changes
232    fn reassign_shortcuts(&mut self) {
233        for (i, session) in self.sessions.iter_mut().enumerate() {
234            if i < self.max_shortcuts {
235                session.shortcut_key = Some((i + 1) as u8);
236            } else {
237                session.shortcut_key = None;
238            }
239        }
240    }
241
242    /// Get the active session
243    pub fn active_session(&self) -> Option<&SessionEntry> {
244        self.active_session_id
245            .and_then(|id| self.sessions.iter().find(|s| s.id == id))
246    }
247
248    /// Handle key events
249    pub fn handle_key(&mut self, key: KeyEvent) -> SessionSwitcherAction {
250        // Alt+[1-9] for quick switching
251        if key.modifiers == KeyModifiers::ALT
252            && let KeyCode::Char(c) = key.code
253            && let Some(digit) = c.to_digit(10)
254            && (1..=9).contains(&digit)
255            && let Some(id) = self.switch_by_shortcut(digit as u8)
256        {
257            return SessionSwitcherAction::Switch(id);
258        }
259
260        // Ctrl+Tab / Ctrl+Shift+Tab for cycling
261        if key.modifiers == KeyModifiers::CONTROL
262            && key.code == KeyCode::Tab
263            && let Some(id) = self.switch_next()
264        {
265            return SessionSwitcherAction::Switch(id);
266        }
267
268        if key.modifiers == (KeyModifiers::CONTROL | KeyModifiers::SHIFT) {
269            match key.code {
270                KeyCode::Tab | KeyCode::BackTab => {
271                    if let Some(id) = self.switch_previous() {
272                        return SessionSwitcherAction::Switch(id);
273                    }
274                }
275                _ => {}
276            }
277        }
278
279        SessionSwitcherAction::None
280    }
281
282    /// Toggle visibility
283    pub const fn toggle_visibility(&mut self) {
284        self.is_visible = !self.is_visible;
285    }
286
287    /// Set compact mode based on terminal width
288    pub const fn set_compact_mode(&mut self, terminal_width: u16) {
289        self.compact_mode = terminal_width < 100;
290    }
291}
292
293impl Default for SessionSwitcherState {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299/// Actions from the session switcher
300#[derive(Debug, Clone, PartialEq)]
301pub enum SessionSwitcherAction {
302    None,
303    Switch(Uuid),
304    Close(Uuid),
305    New,
306}
307
308/// Session switcher widget (tab bar)
309pub struct SessionSwitcher<'a> {
310    state: &'a SessionSwitcherState,
311}
312
313impl<'a> SessionSwitcher<'a> {
314    pub const fn new(state: &'a SessionSwitcherState) -> Self {
315        Self { state }
316    }
317
318    fn get_tab_titles(&self, available_width: u16) -> Vec<String> {
319        if self.state.sessions.is_empty() {
320            return vec!["No sessions".to_string()];
321        }
322
323        let mut titles: Vec<String> = Vec::new();
324        let mut total_width = 0u16;
325
326        // Calculate how much space each tab can use
327        let _max_tab_width = if self.state.compact_mode { 15 } else { 25 };
328
329        for session in &self.state.sessions {
330            let full_title = session.tab_display();
331            let title = if self.state.compact_mode {
332                // In compact mode, show shortened titles
333                let short = session.short_title(12);
334                let modified = if session.is_modified { "*" } else { "" };
335                if let Some(key) = session.shortcut_key {
336                    format!("{}:{}{}", key, short, modified)
337                } else {
338                    format!("{}{}", short, modified)
339                }
340            } else {
341                full_title.clone()
342            };
343
344            let title_width = title.len() as u16 + 3; // Add padding
345
346            if total_width + title_width > available_width && !titles.is_empty() {
347                // Add indicator that there are more sessions
348                if let Some(last) = titles.last_mut() {
349                    *last = format!("{}…", last.trim_end());
350                }
351                break;
352            }
353
354            titles.push(title);
355            total_width += title_width;
356        }
357
358        titles
359    }
360
361    fn render_tabs(&self, area: Rect, buf: &mut Buffer) {
362        let titles = self.get_tab_titles(area.width);
363
364        let active_index = self
365            .state
366            .active_session_id
367            .and_then(|id| self.state.sessions.iter().position(|s| s.id == id))
368            .unwrap_or(0);
369
370        let tabs = Tabs::new(titles)
371            .block(
372                Block::default()
373                    .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
374                    .border_type(BorderType::Rounded)
375                    .border_style(Style::default().fg(Color::DarkGray)),
376            )
377            .select(active_index.min(self.state.sessions.len().saturating_sub(1)))
378            .style(Style::default().fg(Color::Gray))
379            .highlight_style(
380                Style::default()
381                    .fg(Color::White)
382                    .bg(Color::Rgb(40, 40, 40))
383                    .add_modifier(Modifier::BOLD),
384            )
385            .divider(" │ ");
386
387        tabs.render(area, buf);
388    }
389
390    fn render_help(&self, area: Rect, buf: &mut Buffer) {
391        let help_text = if self.state.compact_mode {
392            "Alt+[1-9] • Ctrl+Tab"
393        } else {
394            "Alt+[1-9]: Quick Switch • Ctrl+Tab: Cycle • Ctrl+N: New"
395        };
396
397        let help = Line::from(vec![
398            Span::raw(" "),
399            Span::styled(help_text, Style::default().fg(Color::DarkGray)),
400        ]);
401
402        buf.set_line(area.x, area.y, &help, area.width);
403    }
404}
405
406impl<'a> WidgetRef for SessionSwitcher<'a> {
407    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
408        if !self.state.is_visible || area.height < 1 {
409            return;
410        }
411
412        // Use single line for tabs
413        if area.height == 1 {
414            self.render_tabs(area, buf);
415            return;
416        }
417
418        // If we have 2 lines, show tabs and help
419        let layout = Layout::default()
420            .direction(Direction::Vertical)
421            .constraints([Constraint::Length(1), Constraint::Length(1)])
422            .split(area);
423
424        self.render_tabs(layout[0], buf);
425
426        if area.height >= 2 {
427            self.render_help(layout[1], buf);
428        }
429    }
430}
431
432/// Detailed session switcher popup (for when there are many sessions)
433#[allow(dead_code)]
434pub struct SessionSwitcherPopup<'a> {
435    state: &'a SessionSwitcherState,
436}
437
438impl<'a> SessionSwitcherPopup<'a> {
439    pub const fn new(state: &'a SessionSwitcherState) -> Self {
440        Self { state }
441    }
442}
443
444impl<'a> WidgetRef for SessionSwitcherPopup<'a> {
445    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
446        // Calculate popup dimensions
447        let popup_width = 60.min(area.width - 4);
448        let popup_height = (self.state.sessions.len() as u16 + 4).min(area.height - 4);
449
450        let x = (area.width.saturating_sub(popup_width)) / 2;
451        let y = (area.height.saturating_sub(popup_height)) / 2;
452        let popup_area = Rect::new(x, y, popup_width, popup_height);
453
454        // Clear background
455        for row in popup_area.top()..popup_area.bottom() {
456            for col in popup_area.left()..popup_area.right() {
457                if let Some(cell) = buf.cell_mut((col, row)) {
458                    cell.set_char(' ');
459                    cell.set_style(Style::default().bg(Color::Black));
460                }
461            }
462        }
463
464        // Draw popup border
465        let block = Block::default()
466            .borders(Borders::ALL)
467            .border_type(BorderType::Double)
468            .border_style(Style::default().fg(Color::Cyan))
469            .title(" Session Switcher ");
470
471        let inner = block.inner(popup_area);
472        block.render(popup_area, buf);
473
474        // Render session list
475        for (i, session) in self.state.sessions.iter().enumerate() {
476            if i >= inner.height as usize {
477                break;
478            }
479
480            let y = inner.y + i as u16;
481            let is_active = Some(session.id) == self.state.active_session_id;
482            let is_selected = i == self.state.selected_index;
483
484            let mode_icon = match session.mode {
485                OperatingMode::Plan => "📋",
486                OperatingMode::Build => "🔨",
487                OperatingMode::Review => "🔍",
488            };
489
490            let modified = if session.is_modified { "*" } else { " " };
491            let shortcut = if let Some(key) = session.shortcut_key {
492                format!("Alt+{}", key)
493            } else {
494                "     ".to_string()
495            };
496
497            let line_text = format!(
498                " {} {} {} {}{}",
499                shortcut,
500                mode_icon,
501                session.title,
502                modified,
503                if is_active { " (active)" } else { "" }
504            );
505
506            let style = if is_selected {
507                Style::default().bg(Color::Rgb(40, 40, 40)).fg(Color::White)
508            } else if is_active {
509                Style::default().fg(Color::Cyan)
510            } else {
511                Style::default().fg(Color::Gray)
512            };
513
514            let line = Line::from(line_text).style(style);
515            buf.set_line(inner.x, y, &line, inner.width);
516        }
517
518        // Help text at bottom
519        if popup_area.bottom() < area.height {
520            let help = Line::from(vec![Span::styled(
521                "↑↓: Navigate • Enter: Switch • Esc: Cancel",
522                Style::default().fg(Color::DarkGray),
523            )]);
524
525            let help_y = popup_area.bottom();
526            let help_x = popup_area.x + (popup_area.width.saturating_sub(help.width() as u16)) / 2;
527            buf.set_line(help_x, help_y, &help, popup_area.width);
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use chrono::Utc;
536
537    fn create_test_metadata(id: Uuid, title: &str) -> SessionMetadata {
538        use agcodex_persistence::types::SessionMetadata;
539
540        SessionMetadata {
541            id,
542            title: title.to_string(),
543            created_at: Utc::now(),
544            updated_at: Utc::now(),
545            last_accessed: Utc::now(),
546            message_count: 5,
547            turn_count: 3,
548            current_mode: OperatingMode::Build,
549            model: "gpt-4".to_string(),
550            tags: vec![],
551            is_favorite: false,
552            file_size: 1024,
553            compression_ratio: 0.7,
554            format_version: 1,
555            checkpoints: vec![],
556        }
557    }
558
559    #[test]
560    fn test_session_switcher_shortcuts() {
561        let mut state = SessionSwitcherState::new();
562
563        // Add some sessions
564        let id1 = Uuid::new_v4();
565        let id2 = Uuid::new_v4();
566        let id3 = Uuid::new_v4();
567
568        state.add_session(create_test_metadata(id1, "Session 1"), true);
569        state.add_session(create_test_metadata(id2, "Session 2"), false);
570        state.add_session(create_test_metadata(id3, "Session 3"), false);
571
572        // Check shortcut keys are assigned
573        assert_eq!(state.sessions[0].shortcut_key, Some(1));
574        assert_eq!(state.sessions[1].shortcut_key, Some(2));
575        assert_eq!(state.sessions[2].shortcut_key, Some(3));
576
577        // Test switching by shortcut
578        let switched = state.switch_by_shortcut(2);
579        assert_eq!(switched, Some(state.sessions[1].id));
580        assert_eq!(state.active_session_id, Some(state.sessions[1].id));
581    }
582
583    #[test]
584    fn test_session_cycling() {
585        let mut state = SessionSwitcherState::new();
586
587        let id1 = Uuid::new_v4();
588        let id2 = Uuid::new_v4();
589
590        state.add_session(create_test_metadata(id1, "Session 1"), true);
591        state.add_session(create_test_metadata(id2, "Session 2"), false);
592
593        // Test next cycling
594        let next = state.switch_next();
595        assert!(next.is_some());
596        assert_eq!(state.selected_index, 1);
597
598        // Should wrap around
599        let next = state.switch_next();
600        assert!(next.is_some());
601        assert_eq!(state.selected_index, 0);
602
603        // Test previous cycling
604        let prev = state.switch_previous();
605        assert!(prev.is_some());
606        assert_eq!(state.selected_index, 1);
607    }
608
609    #[test]
610    fn test_modified_tracking() {
611        let mut state = SessionSwitcherState::new();
612
613        let id = Uuid::new_v4();
614        state.add_session(create_test_metadata(id, "Test"), true);
615
616        // Mark as modified
617        state.mark_modified(id, true);
618        assert!(state.sessions[0].is_modified);
619
620        // Unmark
621        state.mark_modified(id, false);
622        assert!(!state.sessions[0].is_modified);
623    }
624}