1use 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#[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>, }
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 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 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#[derive(Debug)]
90pub struct SessionSwitcherState {
91 pub sessions: Vec<SessionEntry>,
93 pub active_session_id: Option<Uuid>,
95 pub selected_index: usize,
97 pub is_visible: bool,
99 pub max_shortcuts: usize,
101 pub modified_sessions: HashMap<Uuid, bool>,
103 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 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 if let Some(pos) = self.sessions.iter().position(|s| s.id == entry.id) {
132 self.sessions[pos] = entry;
134 } else {
135 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 self.sessions
153 .sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
154
155 self.reassign_shortcuts();
157 }
158
159 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 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 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 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 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 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 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 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 pub fn handle_key(&mut self, key: KeyEvent) -> SessionSwitcherAction {
250 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 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 pub const fn toggle_visibility(&mut self) {
284 self.is_visible = !self.is_visible;
285 }
286
287 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#[derive(Debug, Clone, PartialEq)]
301pub enum SessionSwitcherAction {
302 None,
303 Switch(Uuid),
304 Close(Uuid),
305 New,
306}
307
308pub 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 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 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; if total_width + title_width > available_width && !titles.is_empty() {
347 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 if area.height == 1 {
414 self.render_tabs(area, buf);
415 return;
416 }
417
418 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#[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 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 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 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 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 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 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 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 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 let next = state.switch_next();
595 assert!(next.is_some());
596 assert_eq!(state.selected_index, 1);
597
598 let next = state.switch_next();
600 assert!(next.is_some());
601 assert_eq!(state.selected_index, 0);
602
603 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 state.mark_modified(id, true);
618 assert!(state.sessions[0].is_modified);
619
620 state.mark_modified(id, false);
622 assert!(!state.sessions[0].is_modified);
623 }
624}