session_picker.rs (13639B)
1 //! UI component for selecting resumable Claude sessions. 2 3 use crate::path_utils::abbreviate_path; 4 use crate::session_discovery::{discover_sessions, format_relative_time, ResumableSession}; 5 use crate::ui::keybind_hint::paint_keybind_hint; 6 use egui::{RichText, Vec2}; 7 use std::path::{Path, PathBuf}; 8 9 /// Maximum number of sessions to display 10 const MAX_SESSIONS_DISPLAYED: usize = 10; 11 12 /// Actions that can be triggered from the session picker 13 #[derive(Debug, Clone)] 14 pub enum SessionPickerAction { 15 /// User selected a session to resume 16 ResumeSession { 17 cwd: PathBuf, 18 session_id: String, 19 title: String, 20 /// Path to the JSONL file for archive conversion 21 file_path: PathBuf, 22 }, 23 /// User wants to start a new session (no resume) 24 NewSession { cwd: PathBuf }, 25 /// User cancelled and wants to go back to directory picker 26 BackToDirectoryPicker, 27 } 28 29 /// State for the session picker modal 30 pub struct SessionPicker { 31 /// The working directory we're showing sessions for 32 cwd: Option<PathBuf>, 33 /// Cached list of resumable sessions 34 sessions: Vec<ResumableSession>, 35 /// Whether the picker is currently open 36 pub is_open: bool, 37 } 38 39 impl Default for SessionPicker { 40 fn default() -> Self { 41 Self::new() 42 } 43 } 44 45 impl SessionPicker { 46 pub fn new() -> Self { 47 Self { 48 cwd: None, 49 sessions: Vec::new(), 50 is_open: false, 51 } 52 } 53 54 /// Open the picker for a specific working directory 55 pub fn open(&mut self, cwd: PathBuf) { 56 self.sessions = discover_sessions(&cwd); 57 self.cwd = Some(cwd); 58 self.is_open = true; 59 } 60 61 /// Close the picker 62 pub fn close(&mut self) { 63 self.is_open = false; 64 self.cwd = None; 65 self.sessions.clear(); 66 } 67 68 /// Check if there are sessions available to resume 69 pub fn has_sessions(&self) -> bool { 70 !self.sessions.is_empty() 71 } 72 73 /// Get the current working directory 74 pub fn cwd(&self) -> Option<&Path> { 75 self.cwd.as_deref() 76 } 77 78 /// Render the session picker as a full-panel overlay 79 pub fn overlay_ui(&mut self, ui: &mut egui::Ui) -> Option<SessionPickerAction> { 80 let cwd = self.cwd.clone()?; 81 82 let mut action = None; 83 let is_narrow = notedeck::ui::is_narrow(ui.ctx()); 84 let ctrl_held = ui.input(|i| i.modifiers.ctrl); 85 86 // Handle keyboard shortcuts for sessions (Ctrl+1-9) 87 // Only trigger when Ctrl is held to avoid intercepting TextEdit input 88 if ctrl_held { 89 for (idx, session) in self.sessions.iter().take(9).enumerate() { 90 let key = match idx { 91 0 => egui::Key::Num1, 92 1 => egui::Key::Num2, 93 2 => egui::Key::Num3, 94 3 => egui::Key::Num4, 95 4 => egui::Key::Num5, 96 5 => egui::Key::Num6, 97 6 => egui::Key::Num7, 98 7 => egui::Key::Num8, 99 8 => egui::Key::Num9, 100 _ => continue, 101 }; 102 if ui.input(|i| i.key_pressed(key)) { 103 return Some(SessionPickerAction::ResumeSession { 104 cwd, 105 session_id: session.session_id.clone(), 106 title: session.summary.clone(), 107 file_path: session.file_path.clone(), 108 }); 109 } 110 } 111 } 112 113 // Handle Ctrl+N key for new session 114 // Only trigger when Ctrl is held to avoid intercepting TextEdit input 115 if ctrl_held && ui.input(|i| i.key_pressed(egui::Key::N)) { 116 return Some(SessionPickerAction::NewSession { cwd }); 117 } 118 119 // Handle Escape key or Ctrl+B to go back 120 // B key requires Ctrl to avoid intercepting TextEdit input 121 if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) 122 || (ctrl_held && ui.input(|i| i.key_pressed(egui::Key::B))) 123 { 124 return Some(SessionPickerAction::BackToDirectoryPicker); 125 } 126 127 // Full panel frame 128 egui::Frame::new() 129 .fill(ui.visuals().panel_fill) 130 .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20)) 131 .show(ui, |ui| { 132 // Header 133 ui.horizontal(|ui| { 134 if ui.button("< Back").clicked() { 135 action = Some(SessionPickerAction::BackToDirectoryPicker); 136 } 137 ui.add_space(16.0); 138 ui.heading("Resume Session"); 139 }); 140 141 ui.add_space(8.0); 142 143 // Show the cwd 144 ui.label(RichText::new(abbreviate_path(&cwd)).monospace().weak()); 145 146 ui.add_space(16.0); 147 148 // Centered content 149 let max_content_width = if is_narrow { 150 ui.available_width() 151 } else { 152 600.0 153 }; 154 let available_height = ui.available_height(); 155 156 ui.allocate_ui_with_layout( 157 egui::vec2(max_content_width, available_height), 158 egui::Layout::top_down(egui::Align::LEFT), 159 |ui| { 160 // New session button at top 161 ui.horizontal(|ui| { 162 let new_button = egui::Button::new( 163 RichText::new("+ New Session").size(if is_narrow { 164 16.0 165 } else { 166 14.0 167 }), 168 ) 169 .min_size(Vec2::new( 170 if is_narrow { 171 ui.available_width() - 28.0 172 } else { 173 150.0 174 }, 175 if is_narrow { 48.0 } else { 36.0 }, 176 )); 177 178 let response = ui.add(new_button); 179 180 // Show keybind hint when Ctrl is held 181 if ctrl_held { 182 let hint_center = 183 response.rect.right_center() + egui::vec2(14.0, 0.0); 184 paint_keybind_hint(ui, hint_center, "N", 18.0); 185 } 186 187 if response 188 .on_hover_text("Start a new conversation (N)") 189 .clicked() 190 { 191 action = Some(SessionPickerAction::NewSession { cwd: cwd.clone() }); 192 } 193 }); 194 195 ui.add_space(16.0); 196 ui.separator(); 197 ui.add_space(12.0); 198 199 // Sessions list 200 if self.sessions.is_empty() { 201 ui.label( 202 RichText::new("No previous sessions found for this directory.") 203 .weak(), 204 ); 205 } else { 206 ui.label(RichText::new("Recent Sessions").strong()); 207 ui.add_space(8.0); 208 209 let scroll_height = if is_narrow { 210 (ui.available_height() - 80.0).max(100.0) 211 } else { 212 400.0 213 }; 214 215 egui::ScrollArea::vertical() 216 .max_height(scroll_height) 217 .show(ui, |ui| { 218 for (idx, session) in self 219 .sessions 220 .iter() 221 .take(MAX_SESSIONS_DISPLAYED) 222 .enumerate() 223 { 224 let button_height = if is_narrow { 64.0 } else { 50.0 }; 225 let hint_width = 226 if ctrl_held && idx < 9 { 24.0 } else { 0.0 }; 227 let button_width = ui.available_width() - hint_width - 4.0; 228 229 ui.horizontal(|ui| { 230 // Create a frame for the session button 231 let response = ui.add( 232 egui::Button::new("") 233 .min_size(Vec2::new( 234 button_width, 235 button_height, 236 )) 237 .fill( 238 ui.visuals().widgets.inactive.weak_bg_fill, 239 ), 240 ); 241 242 // Draw the content over the button 243 let rect = response.rect; 244 let painter = ui.painter(); 245 246 // Summary text (truncated) 247 let summary_text = &session.summary; 248 let text_color = ui.visuals().text_color(); 249 painter.text( 250 rect.left_top() + egui::vec2(8.0, 8.0), 251 egui::Align2::LEFT_TOP, 252 summary_text, 253 egui::FontId::proportional(13.0), 254 text_color, 255 ); 256 257 // Metadata line (time + message count) 258 let meta_text = format!( 259 "{} • {} messages", 260 format_relative_time(&session.last_timestamp), 261 session.message_count 262 ); 263 painter.text( 264 rect.left_bottom() + egui::vec2(8.0, -8.0), 265 egui::Align2::LEFT_BOTTOM, 266 meta_text, 267 egui::FontId::proportional(11.0), 268 ui.visuals().weak_text_color(), 269 ); 270 271 // Show keybind hint when Ctrl is held 272 if ctrl_held && idx < 9 { 273 let hint_text = format!("{}", idx + 1); 274 let hint_center = response.rect.right_center() 275 + egui::vec2(hint_width / 2.0 + 2.0, 0.0); 276 paint_keybind_hint( 277 ui, 278 hint_center, 279 &hint_text, 280 18.0, 281 ); 282 } 283 284 if response.clicked() { 285 action = Some(SessionPickerAction::ResumeSession { 286 cwd: cwd.clone(), 287 session_id: session.session_id.clone(), 288 title: session.summary.clone(), 289 file_path: session.file_path.clone(), 290 }); 291 } 292 }); 293 294 ui.add_space(4.0); 295 } 296 297 if self.sessions.len() > MAX_SESSIONS_DISPLAYED { 298 ui.add_space(8.0); 299 ui.label( 300 RichText::new(format!( 301 "... and {} more sessions", 302 self.sessions.len() - MAX_SESSIONS_DISPLAYED 303 )) 304 .weak(), 305 ); 306 } 307 }); 308 } 309 }, 310 ); 311 }); 312 313 action 314 } 315 }