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