1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ViewMode {
40 Tree,
42 List,
44 Timeline,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SortBy {
51 LastAccessed,
53 Created,
55 Name,
57 MessageCount,
59 Size,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum FocusedPanel {
66 SessionList,
68 Preview,
70 Actions,
72 Search,
74}
75
76#[derive(Debug, Clone)]
78pub struct SessionBrowser {
79 view_mode: ViewMode,
81 sort_by: SortBy,
83 focused_panel: FocusedPanel,
85 session_index: SessionIndex,
87 filtered_sessions: Vec<Uuid>,
89 search_query: String,
91 session_scroll_state: ScrollState,
93 selected_session: Option<Uuid>,
95 actions: Vec<SessionAction>,
97 action_scroll_state: ScrollState,
99 show_confirmation: bool,
101 confirmation_message: String,
103 show_export_options: bool,
105 favorites_only: bool,
107 show_advanced_filters: bool,
109 date_filter: Option<(DateTime<Utc>, DateTime<Utc>)>,
111 mode_filter: Option<OperatingMode>,
113}
114
115#[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 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 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 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 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 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 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 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 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 pub const fn selected_session_id(&self) -> Option<Uuid> {
287 self.selected_session
288 }
289
290 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 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 pub fn show_confirmation(&mut self, message: String) {
307 self.confirmation_message = message;
308 self.show_confirmation = true;
309 }
310
311 pub fn hide_confirmation(&mut self) {
313 self.show_confirmation = false;
314 self.confirmation_message.clear();
315 }
316
317 pub const fn toggle_export_options(&mut self) {
319 self.show_export_options = !self.show_export_options;
320 }
321
322 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 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 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 self.session_index
346 .search(&self.search_query)
347 .into_iter()
348 .map(|metadata| metadata.id)
349 .collect()
350 };
351
352 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 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 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 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 let action_len = self.actions.len();
427 self.action_scroll_state.clamp_selection(action_len);
428 }
429
430 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 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 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.render(area, buf);
484
485 let main_chunks = Layout::default()
487 .direction(Direction::Vertical)
488 .constraints([
489 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
493 .split(area);
494
495 self.render_header(main_chunks[0], buf);
497
498 let body_chunks = Layout::default()
500 .direction(Direction::Horizontal)
501 .constraints([
502 Constraint::Percentage(40), Constraint::Percentage(60), ])
505 .split(main_chunks[1]);
506
507 self.render_session_list(body_chunks[0], buf);
509
510 let right_chunks = Layout::default()
512 .direction(Direction::Vertical)
513 .constraints([
514 Constraint::Percentage(70), Constraint::Percentage(30), ])
517 .split(body_chunks[1]);
518
519 self.render_preview(right_chunks[0], buf);
521 self.render_actions(right_chunks[1], buf);
522
523 self.render_footer(main_chunks[2], buf);
525
526 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 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 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 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 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 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 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 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 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 if !metadata.checkpoints.is_empty() {
859 }
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 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 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 fn render_confirmation_dialog(&self, area: Rect, buf: &mut Buffer) {
965 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.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 fn render_export_options(&self, area: Rect, buf: &mut Buffer) {
1016 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.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}