agcodex_tui/widgets/
session_browser.rs

1//! Session Browser widget for browsing and managing AGCodex sessions
2//!
3//! Provides a comprehensive interface for:
4//! - Timeline view of all sessions
5//! - Session metadata display
6//! - Branch visualization
7//! - Search across sessions
8//! - Export and management operations
9
10use agcodex_core::modes::OperatingMode;
11use agcodex_persistence::types::SessionIndex;
12use agcodex_persistence::types::SessionMetadata;
13use chrono::DateTime;
14use chrono::Utc;
15use ratatui::buffer::Buffer;
16use ratatui::layout::Constraint;
17use ratatui::layout::Direction;
18use ratatui::layout::Layout;
19use ratatui::layout::Rect;
20use ratatui::style::Color;
21use ratatui::style::Modifier;
22use ratatui::style::Style;
23use ratatui::text::Line;
24use ratatui::text::Span;
25use ratatui::widgets::Block;
26use ratatui::widgets::Borders;
27use ratatui::widgets::Clear;
28use ratatui::widgets::List;
29use ratatui::widgets::ListItem;
30use ratatui::widgets::Paragraph;
31use ratatui::widgets::Widget;
32use ratatui::widgets::WidgetRef;
33use uuid::Uuid;
34
35use crate::bottom_pane::scroll_state::ScrollState;
36
37/// Different view modes for the session browser
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ViewMode {
40    /// Tree view showing session hierarchy and branches
41    Tree,
42    /// Simple list view sorted by criteria
43    List,
44    /// Timeline view showing sessions chronologically
45    Timeline,
46}
47
48/// Sort criteria for session list
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SortBy {
51    /// Sort by last accessed time (most recent first)
52    LastAccessed,
53    /// Sort by creation time (newest first)
54    Created,
55    /// Sort by name alphabetically
56    Name,
57    /// Sort by message count (highest first)
58    MessageCount,
59    /// Sort by file size (largest first)
60    Size,
61}
62
63/// Panel focus state
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum FocusedPanel {
66    /// Left panel: session list/tree
67    SessionList,
68    /// Right panel: preview/details
69    Preview,
70    /// Bottom panel: actions
71    Actions,
72    /// Search input
73    Search,
74}
75
76/// Session browser widget state
77#[derive(Debug, Clone)]
78pub struct SessionBrowser {
79    /// Current view mode
80    view_mode: ViewMode,
81    /// Current sort criteria
82    sort_by: SortBy,
83    /// Which panel is currently focused
84    focused_panel: FocusedPanel,
85    /// Session index with all session metadata
86    session_index: SessionIndex,
87    /// Filtered sessions based on search
88    filtered_sessions: Vec<Uuid>,
89    /// Search query
90    search_query: String,
91    /// Selection state for session list
92    session_scroll_state: ScrollState,
93    /// Currently selected session for preview
94    selected_session: Option<Uuid>,
95    /// Actions available (Open, Delete, Export, etc.)
96    actions: Vec<SessionAction>,
97    /// Selected action index
98    action_scroll_state: ScrollState,
99    /// Whether to show confirmation dialog
100    show_confirmation: bool,
101    /// Confirmation message
102    confirmation_message: String,
103    /// Show export options
104    show_export_options: bool,
105    /// Show favorites only
106    favorites_only: bool,
107    /// Show advanced filters
108    show_advanced_filters: bool,
109    /// Date filter range
110    date_filter: Option<(DateTime<Utc>, DateTime<Utc>)>,
111    /// Mode filter
112    mode_filter: Option<OperatingMode>,
113}
114
115/// Available actions for sessions
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum SessionAction {
118    Open,
119    Delete,
120    Duplicate,
121    Export,
122    Rename,
123    AddToFavorites,
124    RemoveFromFavorites,
125    Archive,
126    AddTags,
127    RemoveTags,
128    ViewBranches,
129    CompareWith,
130    RestoreFromCheckpoint,
131}
132
133impl SessionAction {
134    const fn display_name(&self) -> &'static str {
135        match self {
136            SessionAction::Open => "Open Session",
137            SessionAction::Delete => "Delete Session",
138            SessionAction::Duplicate => "Duplicate Session",
139            SessionAction::Export => "Export as Markdown",
140            SessionAction::Rename => "Rename Session",
141            SessionAction::AddToFavorites => "Add to Favorites",
142            SessionAction::RemoveFromFavorites => "Remove from Favorites",
143            SessionAction::Archive => "Archive Session",
144            SessionAction::AddTags => "Add Tags",
145            SessionAction::RemoveTags => "Remove Tags",
146            SessionAction::ViewBranches => "View Branches",
147            SessionAction::CompareWith => "Compare with Another",
148            SessionAction::RestoreFromCheckpoint => "Restore from Checkpoint",
149        }
150    }
151
152    const fn shortcut(&self) -> Option<&'static str> {
153        match self {
154            SessionAction::Open => Some("Enter"),
155            SessionAction::Delete => Some("Del"),
156            SessionAction::Export => Some("E"),
157            SessionAction::Rename => Some("F2"),
158            SessionAction::AddToFavorites => Some("F"),
159            _ => None,
160        }
161    }
162}
163
164impl SessionBrowser {
165    /// Create a new session browser
166    pub fn new(session_index: SessionIndex) -> Self {
167        let filtered_sessions = session_index.recent_sessions.clone();
168        let actions = vec![
169            SessionAction::Open,
170            SessionAction::Delete,
171            SessionAction::Export,
172            SessionAction::Rename,
173            SessionAction::AddToFavorites,
174            SessionAction::Duplicate,
175            SessionAction::Archive,
176        ];
177
178        let mut browser = Self {
179            view_mode: ViewMode::List,
180            sort_by: SortBy::LastAccessed,
181            focused_panel: FocusedPanel::SessionList,
182            session_index,
183            filtered_sessions,
184            search_query: String::new(),
185            session_scroll_state: ScrollState::new(),
186            selected_session: None,
187            actions,
188            action_scroll_state: ScrollState::new(),
189            show_confirmation: false,
190            confirmation_message: String::new(),
191            show_export_options: false,
192            favorites_only: false,
193            show_advanced_filters: false,
194            date_filter: None,
195            mode_filter: None,
196        };
197
198        browser.refresh_filtered_sessions();
199        browser.update_selection();
200        browser
201    }
202
203    /// Update search query and refresh filtered sessions
204    pub fn set_search_query(&mut self, query: String) {
205        self.search_query = query;
206        self.refresh_filtered_sessions();
207        self.session_scroll_state.reset();
208        self.update_selection();
209    }
210
211    /// Toggle view mode
212    pub fn toggle_view_mode(&mut self) {
213        self.view_mode = match self.view_mode {
214            ViewMode::Tree => ViewMode::List,
215            ViewMode::List => ViewMode::Timeline,
216            ViewMode::Timeline => ViewMode::Tree,
217        };
218        self.refresh_filtered_sessions();
219    }
220
221    /// Cycle through sort options
222    pub fn cycle_sort(&mut self) {
223        self.sort_by = match self.sort_by {
224            SortBy::LastAccessed => SortBy::Created,
225            SortBy::Created => SortBy::Name,
226            SortBy::Name => SortBy::MessageCount,
227            SortBy::MessageCount => SortBy::Size,
228            SortBy::Size => SortBy::LastAccessed,
229        };
230        self.refresh_filtered_sessions();
231    }
232
233    /// Move focus to next panel
234    pub const fn focus_next_panel(&mut self) {
235        self.focused_panel = match self.focused_panel {
236            FocusedPanel::SessionList => FocusedPanel::Preview,
237            FocusedPanel::Preview => FocusedPanel::Actions,
238            FocusedPanel::Actions => FocusedPanel::Search,
239            FocusedPanel::Search => FocusedPanel::SessionList,
240        };
241    }
242
243    /// Move focus to previous panel
244    pub const fn focus_previous_panel(&mut self) {
245        self.focused_panel = match self.focused_panel {
246            FocusedPanel::SessionList => FocusedPanel::Search,
247            FocusedPanel::Search => FocusedPanel::Actions,
248            FocusedPanel::Actions => FocusedPanel::Preview,
249            FocusedPanel::Preview => FocusedPanel::SessionList,
250        };
251    }
252
253    /// Move selection up in current focused panel
254    pub fn move_up(&mut self) {
255        match self.focused_panel {
256            FocusedPanel::SessionList => {
257                let len = self.filtered_sessions.len();
258                self.session_scroll_state.move_up_wrap(len);
259                self.update_selection();
260            }
261            FocusedPanel::Actions => {
262                let len = self.actions.len();
263                self.action_scroll_state.move_up_wrap(len);
264            }
265            _ => {}
266        }
267    }
268
269    /// Move selection down in current focused panel
270    pub fn move_down(&mut self) {
271        match self.focused_panel {
272            FocusedPanel::SessionList => {
273                let len = self.filtered_sessions.len();
274                self.session_scroll_state.move_down_wrap(len);
275                self.update_selection();
276            }
277            FocusedPanel::Actions => {
278                let len = self.actions.len();
279                self.action_scroll_state.move_down_wrap(len);
280            }
281            _ => {}
282        }
283    }
284
285    /// Get currently selected session ID
286    pub const fn selected_session_id(&self) -> Option<Uuid> {
287        self.selected_session
288    }
289
290    /// Get currently selected action
291    pub fn selected_action(&self) -> Option<&SessionAction> {
292        self.action_scroll_state
293            .selected_idx
294            .and_then(|idx| self.actions.get(idx))
295    }
296
297    /// Toggle favorites filter
298    pub fn toggle_favorites_only(&mut self) {
299        self.favorites_only = !self.favorites_only;
300        self.refresh_filtered_sessions();
301        self.session_scroll_state.reset();
302        self.update_selection();
303    }
304
305    /// Show confirmation dialog
306    pub fn show_confirmation(&mut self, message: String) {
307        self.confirmation_message = message;
308        self.show_confirmation = true;
309    }
310
311    /// Hide confirmation dialog
312    pub fn hide_confirmation(&mut self) {
313        self.show_confirmation = false;
314        self.confirmation_message.clear();
315    }
316
317    /// Toggle export options
318    pub const fn toggle_export_options(&mut self) {
319        self.show_export_options = !self.show_export_options;
320    }
321
322    /// Get session metadata for selected session
323    pub fn selected_session_metadata(&self) -> Option<&SessionMetadata> {
324        self.selected_session
325            .and_then(|id| self.session_index.sessions.get(&id))
326    }
327
328    /// Update the session index (e.g., after changes)
329    pub fn update_session_index(&mut self, session_index: SessionIndex) {
330        self.session_index = session_index;
331        self.refresh_filtered_sessions();
332        self.update_selection();
333    }
334
335    /// Refresh filtered sessions based on current criteria
336    fn refresh_filtered_sessions(&mut self) {
337        let mut sessions: Vec<Uuid> = if self.search_query.is_empty() {
338            if self.favorites_only {
339                self.session_index.favorite_sessions.clone()
340            } else {
341                self.session_index.sessions.keys().copied().collect()
342            }
343        } else {
344            // Search in session titles and tags
345            self.session_index
346                .search(&self.search_query)
347                .into_iter()
348                .map(|metadata| metadata.id)
349                .collect()
350        };
351
352        // Apply filters
353        if let Some(mode) = self.mode_filter {
354            sessions.retain(|&id| {
355                self.session_index
356                    .sessions
357                    .get(&id)
358                    .is_some_and(|metadata| metadata.current_mode == mode)
359            });
360        }
361
362        if let Some((start, end)) = self.date_filter {
363            sessions.retain(|&id| {
364                self.session_index
365                    .sessions
366                    .get(&id)
367                    .is_some_and(|metadata| {
368                        metadata.last_accessed >= start && metadata.last_accessed <= end
369                    })
370            });
371        }
372
373        // Sort sessions
374        sessions.sort_by(|&a, &b| {
375            let meta_a = self.session_index.sessions.get(&a);
376            let meta_b = self.session_index.sessions.get(&b);
377
378            match (meta_a, meta_b) {
379                (Some(a), Some(b)) => match self.sort_by {
380                    SortBy::LastAccessed => b.last_accessed.cmp(&a.last_accessed),
381                    SortBy::Created => b.created_at.cmp(&a.created_at),
382                    SortBy::Name => a.title.cmp(&b.title),
383                    SortBy::MessageCount => b.message_count.cmp(&a.message_count),
384                    SortBy::Size => b.file_size.cmp(&a.file_size),
385                },
386                (Some(_), None) => std::cmp::Ordering::Less,
387                (None, Some(_)) => std::cmp::Ordering::Greater,
388                (None, None) => std::cmp::Ordering::Equal,
389            }
390        });
391
392        self.filtered_sessions = sessions;
393    }
394
395    /// Update currently selected session
396    fn update_selection(&mut self) {
397        self.selected_session = self
398            .session_scroll_state
399            .selected_idx
400            .and_then(|idx| self.filtered_sessions.get(idx).copied());
401
402        // Update actions based on selected session
403        if let Some(session_id) = self.selected_session
404            && let Some(metadata) = self.session_index.sessions.get(&session_id)
405        {
406            self.actions = vec![
407                SessionAction::Open,
408                SessionAction::Export,
409                SessionAction::Duplicate,
410                SessionAction::Rename,
411                if metadata.is_favorite {
412                    SessionAction::RemoveFromFavorites
413                } else {
414                    SessionAction::AddToFavorites
415                },
416                SessionAction::Delete,
417                SessionAction::Archive,
418            ];
419
420            if !metadata.checkpoints.is_empty() {
421                self.actions.push(SessionAction::RestoreFromCheckpoint);
422            }
423        }
424
425        // Clamp action selection
426        let action_len = self.actions.len();
427        self.action_scroll_state.clamp_selection(action_len);
428    }
429
430    /// Format duration for display
431    fn format_duration(start: &DateTime<Utc>, end: &DateTime<Utc>) -> String {
432        let duration = *end - *start;
433        let total_seconds = duration.num_seconds().max(0);
434
435        if total_seconds < 60 {
436            format!("{}s", total_seconds)
437        } else if total_seconds < 3600 {
438            format!("{}m", total_seconds / 60)
439        } else if total_seconds < 86400 {
440            format!("{}h", total_seconds / 3600)
441        } else {
442            format!("{}d", total_seconds / 86400)
443        }
444    }
445
446    /// Format file size for display
447    fn format_file_size(bytes: u64) -> String {
448        const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
449        let mut size = bytes as f64;
450        let mut unit_index = 0;
451
452        while size >= 1024.0 && unit_index < UNITS.len() - 1 {
453            size /= 1024.0;
454            unit_index += 1;
455        }
456
457        if unit_index == 0 {
458            format!("{} {}", bytes, UNITS[unit_index])
459        } else {
460            format!("{:.1} {}", size, UNITS[unit_index])
461        }
462    }
463
464    /// Get display string for operating mode
465    const fn mode_display(mode: OperatingMode) -> (&'static str, Color) {
466        match mode {
467            OperatingMode::Plan => ("📋 Plan", Color::Blue),
468            OperatingMode::Build => ("🔨 Build", Color::Green),
469            OperatingMode::Review => ("🔍 Review", Color::Yellow),
470        }
471    }
472}
473
474impl Widget for SessionBrowser {
475    fn render(self, area: Rect, buf: &mut Buffer) {
476        WidgetRef::render_ref(&self, area, buf);
477    }
478}
479
480impl WidgetRef for SessionBrowser {
481    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
482        // Clear the area
483        Clear.render(area, buf);
484
485        // Main layout: [Header][Body][Footer]
486        let main_chunks = Layout::default()
487            .direction(Direction::Vertical)
488            .constraints([
489                Constraint::Length(3), // Header
490                Constraint::Min(10),   // Body
491                Constraint::Length(3), // Footer
492            ])
493            .split(area);
494
495        // Render header
496        self.render_header(main_chunks[0], buf);
497
498        // Body layout: [Left Panel][Right Panel]
499        let body_chunks = Layout::default()
500            .direction(Direction::Horizontal)
501            .constraints([
502                Constraint::Percentage(40), // Session list
503                Constraint::Percentage(60), // Preview + Actions
504            ])
505            .split(main_chunks[1]);
506
507        // Render left panel (session list)
508        self.render_session_list(body_chunks[0], buf);
509
510        // Right panel layout: [Preview][Actions]
511        let right_chunks = Layout::default()
512            .direction(Direction::Vertical)
513            .constraints([
514                Constraint::Percentage(70), // Preview
515                Constraint::Percentage(30), // Actions
516            ])
517            .split(body_chunks[1]);
518
519        // Render right panels
520        self.render_preview(right_chunks[0], buf);
521        self.render_actions(right_chunks[1], buf);
522
523        // Render footer
524        self.render_footer(main_chunks[2], buf);
525
526        // Render overlays
527        if self.show_confirmation {
528            self.render_confirmation_dialog(area, buf);
529        }
530
531        if self.show_export_options {
532            self.render_export_options(area, buf);
533        }
534    }
535}
536
537impl SessionBrowser {
538    /// Render the header with title and controls
539    fn render_header(&self, area: Rect, buf: &mut Buffer) {
540        let title = format!(
541            "Session Browser - {} {} ({})",
542            match self.view_mode {
543                ViewMode::Tree => "Tree",
544                ViewMode::List => "List",
545                ViewMode::Timeline => "Timeline",
546            },
547            match self.sort_by {
548                SortBy::LastAccessed => "Last Accessed",
549                SortBy::Created => "Created",
550                SortBy::Name => "Name",
551                SortBy::MessageCount => "Messages",
552                SortBy::Size => "Size",
553            },
554            self.filtered_sessions.len()
555        );
556
557        let style = if self.focused_panel == FocusedPanel::Search {
558            Style::default()
559                .fg(Color::Yellow)
560                .add_modifier(Modifier::BOLD)
561        } else {
562            Style::default().fg(Color::White)
563        };
564
565        let block = Block::default()
566            .borders(Borders::ALL)
567            .title(title)
568            .border_style(style);
569
570        let inner = block.inner(area);
571        block.render(area, buf);
572
573        // Search query display
574        let search_text = if self.search_query.is_empty() {
575            "Search sessions... (Press / to search)".to_string()
576        } else {
577            format!("Search: {}", self.search_query)
578        };
579
580        let search_style = if self.search_query.is_empty() {
581            Style::default()
582                .fg(Color::DarkGray)
583                .add_modifier(Modifier::ITALIC)
584        } else {
585            Style::default().fg(Color::White)
586        };
587
588        Paragraph::new(search_text)
589            .style(search_style)
590            .render(inner, buf);
591    }
592
593    /// Render the session list panel
594    fn render_session_list(&self, area: Rect, buf: &mut Buffer) {
595        let style = if self.focused_panel == FocusedPanel::SessionList {
596            Style::default().fg(Color::Yellow)
597        } else {
598            Style::default().fg(Color::White)
599        };
600
601        let block = Block::default()
602            .borders(Borders::ALL)
603            .title("Sessions")
604            .border_style(style);
605
606        let inner = block.inner(area);
607        block.render(area, buf);
608
609        if self.filtered_sessions.is_empty() {
610            let empty_msg = if self.search_query.is_empty() {
611                "No sessions found"
612            } else {
613                "No sessions match your search"
614            };
615
616            Paragraph::new(empty_msg)
617                .style(
618                    Style::default()
619                        .fg(Color::DarkGray)
620                        .add_modifier(Modifier::ITALIC),
621                )
622                .render(inner, buf);
623            return;
624        }
625
626        // Calculate visible range
627        let visible_height = inner.height as usize;
628        let start_idx = self
629            .session_scroll_state
630            .scroll_top
631            .min(self.filtered_sessions.len().saturating_sub(visible_height));
632        let end_idx = (start_idx + visible_height).min(self.filtered_sessions.len());
633
634        // Create list items
635        let items: Vec<ListItem> = self.filtered_sessions[start_idx..end_idx]
636            .iter()
637            .enumerate()
638            .filter_map(|(local_idx, &session_id)| {
639                let global_idx = start_idx + local_idx;
640                let metadata = self.session_index.sessions.get(&session_id)?;
641
642                let is_selected = Some(global_idx) == self.session_scroll_state.selected_idx;
643                let is_favorite = metadata.is_favorite;
644
645                let (mode_icon, mode_color) = Self::mode_display(metadata.current_mode);
646
647                let mut spans = vec![
648                    Span::styled(
649                        if is_favorite { "★ " } else { "  " },
650                        Style::default().fg(Color::Yellow),
651                    ),
652                    Span::styled(mode_icon, Style::default().fg(mode_color)),
653                    Span::raw(" "),
654                    Span::styled(
655                        &metadata.title,
656                        Style::default()
657                            .fg(Color::White)
658                            .add_modifier(if is_selected {
659                                Modifier::BOLD
660                            } else {
661                                Modifier::empty()
662                            }),
663                    ),
664                ];
665
666                // Add message count and size info
667                spans.extend_from_slice(&[
668                    Span::raw(" "),
669                    Span::styled(
670                        format!(
671                            "({} msgs, {})",
672                            metadata.message_count,
673                            Self::format_file_size(metadata.file_size)
674                        ),
675                        Style::default().fg(Color::DarkGray),
676                    ),
677                ]);
678
679                let line = Line::from(spans);
680                let item_style = if is_selected {
681                    Style::default().bg(Color::DarkGray)
682                } else {
683                    Style::default()
684                };
685
686                Some(ListItem::new(line).style(item_style))
687            })
688            .collect();
689
690        List::new(items).render(inner, buf);
691    }
692
693    /// Render the preview panel
694    fn render_preview(&self, area: Rect, buf: &mut Buffer) {
695        let style = if self.focused_panel == FocusedPanel::Preview {
696            Style::default().fg(Color::Yellow)
697        } else {
698            Style::default().fg(Color::White)
699        };
700
701        let block = Block::default()
702            .borders(Borders::ALL)
703            .title("Preview")
704            .border_style(style);
705
706        let inner = block.inner(area);
707        block.render(area, buf);
708
709        if let Some(metadata) = self.selected_session_metadata() {
710            let (mode_icon, mode_color) = Self::mode_display(metadata.current_mode);
711
712            let lines = vec![
713                Line::from(vec![
714                    Span::styled(
715                        "Title: ",
716                        Style::default()
717                            .fg(Color::Cyan)
718                            .add_modifier(Modifier::BOLD),
719                    ),
720                    Span::styled(&metadata.title, Style::default().fg(Color::White)),
721                ]),
722                Line::from(vec![
723                    Span::styled(
724                        "Mode: ",
725                        Style::default()
726                            .fg(Color::Cyan)
727                            .add_modifier(Modifier::BOLD),
728                    ),
729                    Span::styled(mode_icon, Style::default().fg(mode_color)),
730                ]),
731                Line::from(vec![
732                    Span::styled(
733                        "Model: ",
734                        Style::default()
735                            .fg(Color::Cyan)
736                            .add_modifier(Modifier::BOLD),
737                    ),
738                    Span::styled(&metadata.model, Style::default().fg(Color::White)),
739                ]),
740                Line::from(""),
741                Line::from(vec![
742                    Span::styled(
743                        "Created: ",
744                        Style::default()
745                            .fg(Color::Cyan)
746                            .add_modifier(Modifier::BOLD),
747                    ),
748                    Span::styled(
749                        metadata.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
750                        Style::default().fg(Color::White),
751                    ),
752                ]),
753                Line::from(vec![
754                    Span::styled(
755                        "Last Accessed: ",
756                        Style::default()
757                            .fg(Color::Cyan)
758                            .add_modifier(Modifier::BOLD),
759                    ),
760                    Span::styled(
761                        metadata
762                            .last_accessed
763                            .format("%Y-%m-%d %H:%M:%S")
764                            .to_string(),
765                        Style::default().fg(Color::White),
766                    ),
767                ]),
768                Line::from(vec![
769                    Span::styled(
770                        "Duration: ",
771                        Style::default()
772                            .fg(Color::Cyan)
773                            .add_modifier(Modifier::BOLD),
774                    ),
775                    Span::styled(
776                        Self::format_duration(&metadata.created_at, &metadata.updated_at),
777                        Style::default().fg(Color::White),
778                    ),
779                ]),
780                Line::from(""),
781                Line::from(vec![
782                    Span::styled(
783                        "Messages: ",
784                        Style::default()
785                            .fg(Color::Cyan)
786                            .add_modifier(Modifier::BOLD),
787                    ),
788                    Span::styled(
789                        metadata.message_count.to_string(),
790                        Style::default().fg(Color::White),
791                    ),
792                ]),
793                Line::from(vec![
794                    Span::styled(
795                        "Turns: ",
796                        Style::default()
797                            .fg(Color::Cyan)
798                            .add_modifier(Modifier::BOLD),
799                    ),
800                    Span::styled(
801                        metadata.turn_count.to_string(),
802                        Style::default().fg(Color::White),
803                    ),
804                ]),
805                Line::from(vec![
806                    Span::styled(
807                        "File Size: ",
808                        Style::default()
809                            .fg(Color::Cyan)
810                            .add_modifier(Modifier::BOLD),
811                    ),
812                    Span::styled(
813                        Self::format_file_size(metadata.file_size),
814                        Style::default().fg(Color::White),
815                    ),
816                ]),
817                Line::from(vec![
818                    Span::styled(
819                        "Compression: ",
820                        Style::default()
821                            .fg(Color::Cyan)
822                            .add_modifier(Modifier::BOLD),
823                    ),
824                    Span::styled(
825                        format!("{:.1}%", metadata.compression_ratio * 100.0),
826                        Style::default().fg(Color::White),
827                    ),
828                ]),
829            ];
830
831            // Add tags if present
832            if !metadata.tags.is_empty() {
833                let mut tag_lines = vec![
834                    Line::from(""),
835                    Line::from(vec![Span::styled(
836                        "Tags: ",
837                        Style::default()
838                            .fg(Color::Cyan)
839                            .add_modifier(Modifier::BOLD),
840                    )]),
841                ];
842
843                for tag in &metadata.tags {
844                    tag_lines.push(Line::from(vec![
845                        Span::raw("  • "),
846                        Span::styled(tag, Style::default().fg(Color::Green)),
847                    ]));
848                }
849
850                let mut all_lines = lines;
851                all_lines.extend(tag_lines);
852                Paragraph::new(all_lines).render(inner, buf);
853            } else {
854                Paragraph::new(lines).render(inner, buf);
855            }
856
857            // Add checkpoints info if present
858            if !metadata.checkpoints.is_empty() {
859                // This could be extended to show checkpoint details
860            }
861        } else {
862            Paragraph::new("No session selected")
863                .style(
864                    Style::default()
865                        .fg(Color::DarkGray)
866                        .add_modifier(Modifier::ITALIC),
867                )
868                .render(inner, buf);
869        }
870    }
871
872    /// Render the actions panel
873    fn render_actions(&self, area: Rect, buf: &mut Buffer) {
874        let style = if self.focused_panel == FocusedPanel::Actions {
875            Style::default().fg(Color::Yellow)
876        } else {
877            Style::default().fg(Color::White)
878        };
879
880        let block = Block::default()
881            .borders(Borders::ALL)
882            .title("Actions")
883            .border_style(style);
884
885        let inner = block.inner(area);
886        block.render(area, buf);
887
888        if self.actions.is_empty() {
889            Paragraph::new("No actions available")
890                .style(
891                    Style::default()
892                        .fg(Color::DarkGray)
893                        .add_modifier(Modifier::ITALIC),
894                )
895                .render(inner, buf);
896            return;
897        }
898
899        let items: Vec<ListItem> = self
900            .actions
901            .iter()
902            .enumerate()
903            .map(|(idx, action)| {
904                let is_selected = Some(idx) == self.action_scroll_state.selected_idx;
905
906                let mut spans = vec![Span::styled(
907                    action.display_name(),
908                    Style::default()
909                        .fg(Color::White)
910                        .add_modifier(if is_selected {
911                            Modifier::BOLD
912                        } else {
913                            Modifier::empty()
914                        }),
915                )];
916
917                if let Some(shortcut) = action.shortcut() {
918                    spans.extend_from_slice(&[
919                        Span::raw(" "),
920                        Span::styled(
921                            format!("({})", shortcut),
922                            Style::default().fg(Color::DarkGray),
923                        ),
924                    ]);
925                }
926
927                let item_style = if is_selected {
928                    Style::default().bg(Color::DarkGray)
929                } else {
930                    Style::default()
931                };
932
933                ListItem::new(Line::from(spans)).style(item_style)
934            })
935            .collect();
936
937        List::new(items).render(inner, buf);
938    }
939
940    /// Render the footer with help text
941    fn render_footer(&self, area: Rect, buf: &mut Buffer) {
942        let help_text = match self.focused_panel {
943            FocusedPanel::SessionList => {
944                "↑/↓: Navigate | Enter: Open | Del: Delete | Tab: Next Panel | /: Search | V: View Mode | S: Sort"
945            }
946            FocusedPanel::Preview => "Tab: Next Panel | Enter: Open Session",
947            FocusedPanel::Actions => "↑/↓: Navigate | Enter: Execute Action | Tab: Next Panel",
948            FocusedPanel::Search => {
949                "Type to search | Enter: Confirm | Esc: Cancel | Tab: Next Panel"
950            }
951        };
952
953        let block = Block::default().borders(Borders::ALL).title("Help");
954
955        let inner = block.inner(area);
956        block.render(area, buf);
957
958        Paragraph::new(help_text)
959            .style(Style::default().fg(Color::DarkGray))
960            .render(inner, buf);
961    }
962
963    /// Render confirmation dialog
964    fn render_confirmation_dialog(&self, area: Rect, buf: &mut Buffer) {
965        // Center the dialog
966        let dialog_width = 50.min(area.width.saturating_sub(4));
967        let dialog_height = 7.min(area.height.saturating_sub(4));
968
969        let dialog_x = (area.width.saturating_sub(dialog_width)) / 2;
970        let dialog_y = (area.height.saturating_sub(dialog_height)) / 2;
971
972        let dialog_area = Rect {
973            x: area.x + dialog_x,
974            y: area.y + dialog_y,
975            width: dialog_width,
976            height: dialog_height,
977        };
978
979        // Clear background
980        Clear.render(dialog_area, buf);
981
982        let block = Block::default()
983            .borders(Borders::ALL)
984            .title("Confirm Action")
985            .border_style(Style::default().fg(Color::Red));
986
987        let inner = block.inner(dialog_area);
988        block.render(dialog_area, buf);
989
990        let lines = vec![
991            Line::from(self.confirmation_message.as_str()),
992            Line::from(""),
993            Line::from(vec![
994                Span::styled(
995                    "Y",
996                    Style::default()
997                        .fg(Color::Green)
998                        .add_modifier(Modifier::BOLD),
999                ),
1000                Span::raw("es / "),
1001                Span::styled(
1002                    "N",
1003                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1004                ),
1005                Span::raw("o"),
1006            ]),
1007        ];
1008
1009        Paragraph::new(lines)
1010            .style(Style::default().fg(Color::White))
1011            .render(inner, buf);
1012    }
1013
1014    /// Render export options dialog
1015    fn render_export_options(&self, area: Rect, buf: &mut Buffer) {
1016        // Center the dialog
1017        let dialog_width = 40.min(area.width.saturating_sub(4));
1018        let dialog_height = 10.min(area.height.saturating_sub(4));
1019
1020        let dialog_x = (area.width.saturating_sub(dialog_width)) / 2;
1021        let dialog_y = (area.height.saturating_sub(dialog_height)) / 2;
1022
1023        let dialog_area = Rect {
1024            x: area.x + dialog_x,
1025            y: area.y + dialog_y,
1026            width: dialog_width,
1027            height: dialog_height,
1028        };
1029
1030        // Clear background
1031        Clear.render(dialog_area, buf);
1032
1033        let block = Block::default()
1034            .borders(Borders::ALL)
1035            .title("Export Options")
1036            .border_style(Style::default().fg(Color::Blue));
1037
1038        let inner = block.inner(dialog_area);
1039        block.render(dialog_area, buf);
1040
1041        let lines = vec![
1042            Line::from("Choose export format:"),
1043            Line::from(""),
1044            Line::from(vec![
1045                Span::styled(
1046                    "1",
1047                    Style::default()
1048                        .fg(Color::Yellow)
1049                        .add_modifier(Modifier::BOLD),
1050                ),
1051                Span::raw(". Markdown (conversation only)"),
1052            ]),
1053            Line::from(vec![
1054                Span::styled(
1055                    "2",
1056                    Style::default()
1057                        .fg(Color::Yellow)
1058                        .add_modifier(Modifier::BOLD),
1059                ),
1060                Span::raw(". Markdown (with metadata)"),
1061            ]),
1062            Line::from(vec![
1063                Span::styled(
1064                    "3",
1065                    Style::default()
1066                        .fg(Color::Yellow)
1067                        .add_modifier(Modifier::BOLD),
1068                ),
1069                Span::raw(". JSON (complete data)"),
1070            ]),
1071            Line::from(vec![
1072                Span::styled(
1073                    "4",
1074                    Style::default()
1075                        .fg(Color::Yellow)
1076                        .add_modifier(Modifier::BOLD),
1077                ),
1078                Span::raw(". Plain text"),
1079            ]),
1080            Line::from(""),
1081            Line::from("Press Esc to cancel"),
1082        ];
1083
1084        Paragraph::new(lines)
1085            .style(Style::default().fg(Color::White))
1086            .render(inner, buf);
1087    }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093    use chrono::Utc;
1094    use uuid::Uuid;
1095
1096    fn create_test_session_metadata(title: &str) -> SessionMetadata {
1097        SessionMetadata {
1098            id: Uuid::new_v4(),
1099            title: title.to_string(),
1100            created_at: Utc::now(),
1101            updated_at: Utc::now(),
1102            last_accessed: Utc::now(),
1103            message_count: 10,
1104            turn_count: 5,
1105            current_mode: OperatingMode::Build,
1106            model: "gpt-4".to_string(),
1107            tags: vec!["test".to_string()],
1108            is_favorite: false,
1109            file_size: 1024,
1110            compression_ratio: 0.85,
1111            format_version: 1,
1112            checkpoints: vec![],
1113        }
1114    }
1115
1116    #[test]
1117    fn test_session_browser_creation() {
1118        let session_index = SessionIndex::new();
1119        let browser = SessionBrowser::new(session_index);
1120
1121        assert_eq!(browser.view_mode, ViewMode::List);
1122        assert_eq!(browser.sort_by, SortBy::LastAccessed);
1123        assert_eq!(browser.focused_panel, FocusedPanel::SessionList);
1124        assert!(browser.search_query.is_empty());
1125    }
1126
1127    #[test]
1128    fn test_view_mode_toggle() {
1129        let session_index = SessionIndex::new();
1130        let mut browser = SessionBrowser::new(session_index);
1131
1132        assert_eq!(browser.view_mode, ViewMode::List);
1133
1134        browser.toggle_view_mode();
1135        assert_eq!(browser.view_mode, ViewMode::Timeline);
1136
1137        browser.toggle_view_mode();
1138        assert_eq!(browser.view_mode, ViewMode::Tree);
1139
1140        browser.toggle_view_mode();
1141        assert_eq!(browser.view_mode, ViewMode::List);
1142    }
1143
1144    #[test]
1145    fn test_search_functionality() {
1146        let mut session_index = SessionIndex::new();
1147        let metadata1 = create_test_session_metadata("Test Session 1");
1148        let metadata2 = create_test_session_metadata("Another Session");
1149
1150        session_index.add_session(metadata1);
1151        session_index.add_session(metadata2);
1152
1153        let mut browser = SessionBrowser::new(session_index);
1154        assert_eq!(browser.filtered_sessions.len(), 2);
1155
1156        browser.set_search_query("Test".to_string());
1157        assert_eq!(browser.filtered_sessions.len(), 2);
1158
1159        browser.set_search_query("Session".to_string());
1160        assert_eq!(browser.filtered_sessions.len(), 2);
1161
1162        browser.set_search_query("NonExistent".to_string());
1163        assert_eq!(browser.filtered_sessions.len(), 0);
1164    }
1165
1166    #[test]
1167    fn test_panel_focus_cycling() {
1168        let session_index = SessionIndex::new();
1169        let mut browser = SessionBrowser::new(session_index);
1170
1171        assert_eq!(browser.focused_panel, FocusedPanel::SessionList);
1172
1173        browser.focus_next_panel();
1174        assert_eq!(browser.focused_panel, FocusedPanel::Preview);
1175
1176        browser.focus_next_panel();
1177        assert_eq!(browser.focused_panel, FocusedPanel::Actions);
1178
1179        browser.focus_next_panel();
1180        assert_eq!(browser.focused_panel, FocusedPanel::Search);
1181
1182        browser.focus_next_panel();
1183        assert_eq!(browser.focused_panel, FocusedPanel::SessionList);
1184    }
1185
1186    #[test]
1187    fn test_format_file_size() {
1188        assert_eq!(SessionBrowser::format_file_size(512), "512 B");
1189        assert_eq!(SessionBrowser::format_file_size(1024), "1.0 KB");
1190        assert_eq!(SessionBrowser::format_file_size(1536), "1.5 KB");
1191        assert_eq!(SessionBrowser::format_file_size(1048576), "1.0 MB");
1192    }
1193
1194    #[test]
1195    fn test_sort_cycling() {
1196        let session_index = SessionIndex::new();
1197        let mut browser = SessionBrowser::new(session_index);
1198
1199        assert_eq!(browser.sort_by, SortBy::LastAccessed);
1200
1201        browser.cycle_sort();
1202        assert_eq!(browser.sort_by, SortBy::Created);
1203
1204        browser.cycle_sort();
1205        assert_eq!(browser.sort_by, SortBy::Name);
1206
1207        browser.cycle_sort();
1208        assert_eq!(browser.sort_by, SortBy::MessageCount);
1209
1210        browser.cycle_sort();
1211        assert_eq!(browser.sort_by, SortBy::Size);
1212
1213        browser.cycle_sort();
1214        assert_eq!(browser.sort_by, SortBy::LastAccessed);
1215    }
1216}