notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit e9f56cf554c7434d4582d941033ce1ea58d5fe75
parent 8d2c748fb50201a1b9ede533e53cdf837cca8a54
Author: William Casarin <jb55@jb55.com>
Date:   Wed,  4 Feb 2026 23:33:45 -0800

wip(dave): add session resume support (untested)

Add --resume equivalent for the cwd in the multi-headed headless Claude
GUI. When selecting a directory, the app now scans for existing Claude
sessions and offers to resume them.

New components:
- session_discovery.rs: Scans ~/.claude/projects/<path>/ for .jsonl
  session files and extracts metadata (session ID, timestamp, summary)
- session_picker.rs: Full-screen overlay UI for selecting sessions to
  resume, with keyboard shortcuts (1-9, N for new, B to go back)

Changes:
- ChatSession now has resume_session_id field
- SessionManager has new_resumed_session() method
- ClaudeBackend passes --resume flag via SDK when resuming
- Directory picker flow now shows session picker when resumable
  sessions exist for the selected directory

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 44+++++++++++++++++++++++++++++++++++++++-----
Mcrates/notedeck_dave/src/backend/openai.rs | 1+
Mcrates/notedeck_dave/src/backend/traits.rs | 4++++
Mcrates/notedeck_dave/src/lib.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/notedeck_dave/src/session.rs | 35+++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/session_discovery.rs | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 2++
Acrates/notedeck_dave/src/ui/session_picker.rs | 313+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 725 insertions(+), 11 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -130,6 +130,7 @@ struct PermissionRequestInternal { async fn session_actor( session_id: String, cwd: Option<PathBuf>, + resume_session_id: Option<String>, mut command_rx: tokio_mpsc::Receiver<SessionCommand>, ) { // Permission channel - the callback sends to perm_tx, actor receives on perm_rx @@ -183,16 +184,41 @@ async fn session_actor( tracing::trace!("Claude CLI stderr: {}", msg); }); + // Log if we're resuming a session + if let Some(ref resume_id) = resume_session_id { + tracing::info!( + "Session {} will resume Claude session: {}", + session_id, + resume_id + ); + } + // Create client once - this maintains the persistent connection - let options = match cwd { - Some(ref dir) => ClaudeAgentOptions::builder() + // Using match to handle the TypedBuilder's strict type requirements + let options = match (&cwd, &resume_session_id) { + (Some(dir), Some(resume_id)) => ClaudeAgentOptions::builder() .permission_mode(PermissionMode::Default) .stderr_callback(stderr_callback) .can_use_tool(can_use_tool) .include_partial_messages(true) .cwd(dir) + .resume(resume_id) + .build(), + (Some(dir), None) => ClaudeAgentOptions::builder() + .permission_mode(PermissionMode::Default) + .stderr_callback(stderr_callback) + .can_use_tool(can_use_tool) + .include_partial_messages(true) + .cwd(dir) + .build(), + (None, Some(resume_id)) => ClaudeAgentOptions::builder() + .permission_mode(PermissionMode::Default) + .stderr_callback(stderr_callback) + .can_use_tool(can_use_tool) + .include_partial_messages(true) + .resume(resume_id) .build(), - None => ClaudeAgentOptions::builder() + (None, None) => ClaudeAgentOptions::builder() .permission_mode(PermissionMode::Default) .stderr_callback(stderr_callback) .can_use_tool(can_use_tool) @@ -551,6 +577,7 @@ impl AiBackend for ClaudeBackend { _user_id: String, session_id: String, cwd: Option<PathBuf>, + resume_session_id: Option<String>, ctx: egui::Context, ) -> ( mpsc::Receiver<DaveApiResponse>, @@ -586,11 +613,18 @@ impl AiBackend for ClaudeBackend { let handle = entry.or_insert_with(|| { let (command_tx, command_rx) = tokio_mpsc::channel(16); - // Spawn session actor with cwd + // Spawn session actor with cwd and optional resume session ID let session_id_clone = session_id.clone(); let cwd_clone = cwd.clone(); + let resume_session_id_clone = resume_session_id.clone(); tokio::spawn(async move { - session_actor(session_id_clone, cwd_clone, command_rx).await; + session_actor( + session_id_clone, + cwd_clone, + resume_session_id_clone, + command_rx, + ) + .await; }); SessionHandle { command_tx } diff --git a/crates/notedeck_dave/src/backend/openai.rs b/crates/notedeck_dave/src/backend/openai.rs @@ -35,6 +35,7 @@ impl AiBackend for OpenAiBackend { user_id: String, _session_id: String, _cwd: Option<PathBuf>, + _resume_session_id: Option<String>, ctx: egui::Context, ) -> ( mpsc::Receiver<DaveApiResponse>, diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs @@ -19,6 +19,9 @@ pub trait AiBackend: Send + Sync { /// /// Returns a receiver that will receive tokens and tool calls as they arrive, /// plus an optional JoinHandle to the spawned task for cleanup on session deletion. + /// + /// If `resume_session_id` is Some, the backend should resume the specified Claude + /// session instead of starting a new conversation. #[allow(clippy::too_many_arguments)] fn stream_request( &self, @@ -28,6 +31,7 @@ pub trait AiBackend: Send + Sync { user_id: String, session_id: String, cwd: Option<PathBuf>, + resume_session_id: Option<String>, ctx: egui::Context, ) -> ( mpsc::Receiver<DaveApiResponse>, diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -10,6 +10,7 @@ pub(crate) mod mesh; mod messages; mod quaternion; pub mod session; +pub mod session_discovery; mod tools; mod ui; mod vec3; @@ -36,6 +37,7 @@ pub use messages::{ }; pub use quaternion::Quaternion; pub use session::{ChatSession, SessionId, SessionManager}; +pub use session_discovery::{discover_sessions, format_relative_time, ResumableSession}; pub use tools::{ PartialToolCall, QueryCall, QueryResponse, Tool, ToolCall, ToolCalls, ToolResponse, ToolResponses, @@ -43,7 +45,7 @@ pub use tools::{ pub use ui::{ check_keybindings, AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, DirectoryPicker, DirectoryPickerAction, KeyAction, SceneAction, SceneResponse, - SessionListAction, SessionListUi, SettingsPanelAction, + SessionListAction, SessionListUi, SessionPicker, SessionPickerAction, SettingsPanelAction, }; pub use vec3::Vec3; @@ -54,6 +56,7 @@ pub enum DaveOverlay { None, Settings, DirectoryPicker, + SessionPicker, } pub struct Dave { @@ -87,6 +90,8 @@ pub struct Dave { home_session: Option<SessionId>, /// Directory picker for selecting working directory when creating sessions directory_picker: DirectoryPicker, + /// Session picker for resuming existing Claude sessions + session_picker: SessionPicker, /// Current overlay taking over the UI (if any) active_overlay: DaveOverlay, /// IPC listener for external spawn-agent commands @@ -184,6 +189,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr auto_steal_focus: false, home_session: None, directory_picker, + session_picker: SessionPicker::new(), // Auto-show directory picker on startup since there are no sessions active_overlay: DaveOverlay::DirectoryPicker, ipc_listener, @@ -390,6 +396,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr match self.active_overlay { DaveOverlay::Settings => return self.settings_overlay_ui(app_ctx, ui), DaveOverlay::DirectoryPicker => return self.directory_picker_overlay_ui(app_ctx, ui), + DaveOverlay::SessionPicker => return self.session_picker_overlay_ui(app_ctx, ui), DaveOverlay::None => {} } @@ -434,8 +441,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(action) = self.directory_picker.overlay_ui(ui, has_sessions) { match action { DirectoryPickerAction::DirectorySelected(path) => { - self.create_session_with_cwd(path); - self.active_overlay = DaveOverlay::None; + // Check if there are resumable sessions for this directory + let resumable_sessions = discover_sessions(&path); + if resumable_sessions.is_empty() { + // No previous sessions, create new directly + self.create_session_with_cwd(path); + self.active_overlay = DaveOverlay::None; + } else { + // Show session picker to let user choose + self.session_picker.open(path); + self.active_overlay = DaveOverlay::SessionPicker; + } } DirectoryPickerAction::Cancelled => { // Only close if there are existing sessions to fall back to @@ -451,6 +467,40 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr DaveResponse::default() } + /// Full-screen session picker overlay (for resuming Claude sessions) + fn session_picker_overlay_ui( + &mut self, + _app_ctx: &mut AppContext, + ui: &mut egui::Ui, + ) -> DaveResponse { + if let Some(action) = self.session_picker.overlay_ui(ui) { + match action { + SessionPickerAction::ResumeSession { + cwd, + session_id, + title, + } => { + // Create a session that resumes the existing Claude conversation + self.create_resumed_session_with_cwd(cwd, session_id, title); + self.session_picker.close(); + self.active_overlay = DaveOverlay::None; + } + SessionPickerAction::NewSession { cwd } => { + // User chose to start fresh + self.create_session_with_cwd(cwd); + self.session_picker.close(); + self.active_overlay = DaveOverlay::None; + } + SessionPickerAction::BackToDirectoryPicker => { + // Go back to directory picker + self.session_picker.close(); + self.active_overlay = DaveOverlay::DirectoryPicker; + } + } + } + DaveResponse::default() + } + /// Scene view with RTS-style agent visualization and chat side panel fn scene_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { use egui_extras::{Size, StripBuilder}; @@ -762,6 +812,30 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Create a new session that resumes an existing Claude conversation + fn create_resumed_session_with_cwd( + &mut self, + cwd: PathBuf, + resume_session_id: String, + title: String, + ) { + // Add to recent directories + self.directory_picker.add_recent(cwd.clone()); + + let id = self + .session_manager + .new_resumed_session(cwd, resume_session_id, title); + // Request focus on the new session's input + if let Some(session) = self.session_manager.get_mut(id) { + session.focus_requested = true; + // Also update scene selection and camera if in scene view + if self.show_scene { + self.scene.select(id); + self.scene.focus_on(session.scene_position); + } + } + } + /// Clone the active agent, creating a new session with the same working directory fn clone_active_agent(&mut self) { if let Some(cwd) = self.session_manager.get_active().map(|s| s.cwd.clone()) { @@ -1476,14 +1550,22 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let session_id = format!("dave-session-{}", session.id); let messages = session.chat.clone(); let cwd = Some(session.cwd.clone()); + let resume_session_id = session.resume_session_id.clone(); let tools = self.tools.clone(); let model_name = self.model_config.model().to_owned(); let ctx = ctx.clone(); // Use backend to stream request - let (rx, task_handle) = self - .backend - .stream_request(messages, tools, model_name, user_id, session_id, cwd, ctx); + let (rx, task_handle) = self.backend.stream_request( + messages, + tools, + model_name, + user_id, + session_id, + cwd, + resume_session_id, + ctx, + ); session.incoming_tokens = Some(rx); session.task_handle = task_handle; } diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -60,6 +60,9 @@ pub struct ChatSession { pub is_compacting: bool, /// Info from the last completed compaction (for display) pub last_compaction: Option<CompactionInfo>, + /// Claude session ID to resume (UUID from Claude CLI's session storage) + /// When set, the backend will use --resume to continue this session + pub resume_session_id: Option<String>, } impl Drop for ChatSession { @@ -98,9 +101,23 @@ impl ChatSession { subagent_indices: HashMap::new(), is_compacting: false, last_compaction: None, + resume_session_id: None, } } + /// Create a new session that resumes an existing Claude conversation + pub fn new_resumed( + id: SessionId, + cwd: PathBuf, + resume_session_id: String, + title: String, + ) -> Self { + let mut session = Self::new(id, cwd); + session.resume_session_id = Some(resume_session_id); + session.title = title; + session + } + /// Update a subagent's output (appending new content, keeping only the tail) pub fn update_subagent_output(&mut self, task_id: &str, new_output: &str) { if let Some(&idx) = self.subagent_indices.get(task_id) { @@ -224,6 +241,24 @@ impl SessionManager { id } + /// Create a new session that resumes an existing Claude conversation + pub fn new_resumed_session( + &mut self, + cwd: PathBuf, + resume_session_id: String, + title: String, + ) -> SessionId { + let id = self.next_id; + self.next_id += 1; + + let session = ChatSession::new_resumed(id, cwd, resume_session_id, title); + self.sessions.insert(id, session); + self.order.insert(0, id); // Most recent first + self.active = Some(id); + + id + } + /// Get a reference to the active session pub fn get_active(&self) -> Option<&ChatSession> { self.active.and_then(|id| self.sessions.get(&id)) diff --git a/crates/notedeck_dave/src/session_discovery.rs b/crates/notedeck_dave/src/session_discovery.rs @@ -0,0 +1,243 @@ +//! Discovers resumable Claude Code sessions from the filesystem. +//! +//! Claude Code stores session data in ~/.claude/projects/<project-path>/ +//! where <project-path> is the cwd with slashes replaced by dashes and leading slash removed. + +use serde::Deserialize; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +/// Information about a resumable Claude session +#[derive(Debug, Clone)] +pub struct ResumableSession { + /// The UUID session identifier used by Claude CLI + pub session_id: String, + /// Path to the session JSONL file + pub file_path: PathBuf, + /// Timestamp of the most recent message + pub last_timestamp: chrono::DateTime<chrono::Utc>, + /// Summary/title derived from first user message + pub summary: String, + /// Number of messages in the session + pub message_count: usize, +} + +/// A message entry from the JSONL file +#[derive(Deserialize)] +struct SessionEntry { + #[serde(rename = "sessionId")] + session_id: Option<String>, + timestamp: Option<String>, + #[serde(rename = "type")] + entry_type: Option<String>, + message: Option<MessageContent>, +} + +#[derive(Deserialize)] +struct MessageContent { + role: Option<String>, + content: Option<serde_json::Value>, +} + +/// Converts a working directory to its Claude project path +/// e.g., /home/jb55/dev/notedeck-dave -> -home-jb55-dev-notedeck-dave +fn cwd_to_project_path(cwd: &Path) -> String { + let path_str = cwd.to_string_lossy(); + // Replace path separators with dashes, keep the leading dash + path_str.replace('/', "-") +} + +/// Get the Claude projects directory +fn get_claude_projects_dir() -> Option<PathBuf> { + dirs::home_dir().map(|home| home.join(".claude").join("projects")) +} + +/// Extract the first user message content as a summary +fn extract_first_user_message(content: &serde_json::Value) -> Option<String> { + match content { + serde_json::Value::String(s) => { + // Clean up the message - remove "Human: " prefix if present + let cleaned = s.trim().strip_prefix("Human:").unwrap_or(s).trim(); + // Take first 60 chars + let summary: String = cleaned.chars().take(60).collect(); + if cleaned.len() > 60 { + Some(format!("{}...", summary)) + } else { + Some(summary.to_string()) + } + } + serde_json::Value::Array(arr) => { + // Content might be an array of content blocks + for item in arr { + if let Some(text) = item.get("text").and_then(|t| t.as_str()) { + let summary: String = text.chars().take(60).collect(); + if text.len() > 60 { + return Some(format!("{}...", summary)); + } else { + return Some(summary.to_string()); + } + } + } + None + } + _ => None, + } +} + +/// Parse a session JSONL file to extract session info +fn parse_session_file(path: &Path) -> Option<ResumableSession> { + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + + let mut session_id: Option<String> = None; + let mut last_timestamp: Option<chrono::DateTime<chrono::Utc>> = None; + let mut first_user_message: Option<String> = None; + let mut message_count = 0; + + for line in reader.lines() { + let line = line.ok()?; + if line.trim().is_empty() { + continue; + } + + if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line) { + // Get session ID from first entry that has it + if session_id.is_none() { + session_id = entry.session_id.clone(); + } + + // Track timestamp + if let Some(ts_str) = &entry.timestamp { + if let Ok(ts) = ts_str.parse::<chrono::DateTime<chrono::Utc>>() { + if last_timestamp.is_none() || ts > last_timestamp.unwrap() { + last_timestamp = Some(ts); + } + } + } + + // Count user/assistant messages + if matches!( + entry.entry_type.as_deref(), + Some("user") | Some("assistant") + ) { + message_count += 1; + + // Get first user message for summary + if entry.entry_type.as_deref() == Some("user") && first_user_message.is_none() { + if let Some(msg) = &entry.message { + if msg.role.as_deref() == Some("user") { + if let Some(content) = &msg.content { + first_user_message = extract_first_user_message(content); + } + } + } + } + } + } + } + + // Need at least a session_id and some messages + let session_id = session_id?; + if message_count == 0 { + return None; + } + + Some(ResumableSession { + session_id, + file_path: path.to_path_buf(), + last_timestamp: last_timestamp.unwrap_or_else(chrono::Utc::now), + summary: first_user_message.unwrap_or_else(|| "(no summary)".to_string()), + message_count, + }) +} + +/// Discover all resumable sessions for a given working directory +pub fn discover_sessions(cwd: &Path) -> Vec<ResumableSession> { + let projects_dir = match get_claude_projects_dir() { + Some(dir) => dir, + None => return Vec::new(), + }; + + let project_path = cwd_to_project_path(cwd); + let session_dir = projects_dir.join(&project_path); + + if !session_dir.exists() || !session_dir.is_dir() { + return Vec::new(); + } + + let mut sessions = Vec::new(); + + // Read all .jsonl files in the session directory + if let Ok(entries) = fs::read_dir(&session_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "jsonl") { + if let Some(session) = parse_session_file(&path) { + sessions.push(session); + } + } + } + } + + // Sort by most recent first + sessions.sort_by(|a, b| b.last_timestamp.cmp(&a.last_timestamp)); + + sessions +} + +/// Format a timestamp for display (relative time like "2 hours ago") +pub fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(*timestamp); + + if duration.num_seconds() < 60 { + "just now".to_string() + } else if duration.num_minutes() < 60 { + let mins = duration.num_minutes(); + format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" }) + } else if duration.num_hours() < 24 { + let hours = duration.num_hours(); + format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" }) + } else if duration.num_days() < 7 { + let days = duration.num_days(); + format!("{} day{} ago", days, if days == 1 { "" } else { "s" }) + } else { + timestamp.format("%Y-%m-%d").to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cwd_to_project_path() { + assert_eq!( + cwd_to_project_path(Path::new("/home/jb55/dev/notedeck-dave")), + "-home-jb55-dev-notedeck-dave" + ); + assert_eq!(cwd_to_project_path(Path::new("/tmp/test")), "-tmp-test"); + } + + #[test] + fn test_extract_first_user_message_string() { + let content = serde_json::json!("Human: Hello, world!\n\n"); + let result = extract_first_user_message(&content); + assert_eq!(result, Some("Hello, world!".to_string())); + } + + #[test] + fn test_extract_first_user_message_array() { + let content = serde_json::json!([{"type": "text", "text": "Test message"}]); + let result = extract_first_user_message(&content); + assert_eq!(result, Some("Test message".to_string())); + } + + #[test] + fn test_extract_first_user_message_truncation() { + let long_content = serde_json::json!("Human: This is a very long message that should be truncated because it exceeds sixty characters in length"); + let result = extract_first_user_message(&long_content); + assert!(result.unwrap().ends_with("...")); + } +} diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -9,6 +9,7 @@ mod pill; mod query_ui; pub mod scene; pub mod session_list; +pub mod session_picker; mod settings; mod top_buttons; @@ -19,4 +20,5 @@ pub use keybind_hint::{keybind_hint, paint_keybind_hint}; pub use keybindings::{check_keybindings, KeyAction}; pub use scene::{AgentScene, SceneAction, SceneResponse}; pub use session_list::{SessionListAction, SessionListUi}; +pub use session_picker::{SessionPicker, SessionPickerAction}; pub use settings::{DaveSettingsPanel, SettingsPanelAction}; diff --git a/crates/notedeck_dave/src/ui/session_picker.rs b/crates/notedeck_dave/src/ui/session_picker.rs @@ -0,0 +1,313 @@ +//! UI component for selecting resumable Claude sessions. + +use crate::session_discovery::{discover_sessions, format_relative_time, ResumableSession}; +use crate::ui::keybind_hint::paint_keybind_hint; +use egui::{RichText, Vec2}; +use std::path::{Path, PathBuf}; + +/// Maximum number of sessions to display +const MAX_SESSIONS_DISPLAYED: usize = 10; + +/// Actions that can be triggered from the session picker +#[derive(Debug, Clone)] +pub enum SessionPickerAction { + /// User selected a session to resume + ResumeSession { + cwd: PathBuf, + session_id: String, + title: String, + }, + /// User wants to start a new session (no resume) + NewSession { cwd: PathBuf }, + /// User cancelled and wants to go back to directory picker + BackToDirectoryPicker, +} + +/// State for the session picker modal +pub struct SessionPicker { + /// The working directory we're showing sessions for + cwd: Option<PathBuf>, + /// Cached list of resumable sessions + sessions: Vec<ResumableSession>, + /// Whether the picker is currently open + pub is_open: bool, +} + +impl Default for SessionPicker { + fn default() -> Self { + Self::new() + } +} + +impl SessionPicker { + pub fn new() -> Self { + Self { + cwd: None, + sessions: Vec::new(), + is_open: false, + } + } + + /// Open the picker for a specific working directory + pub fn open(&mut self, cwd: PathBuf) { + self.sessions = discover_sessions(&cwd); + self.cwd = Some(cwd); + self.is_open = true; + } + + /// Close the picker + pub fn close(&mut self) { + self.is_open = false; + self.cwd = None; + self.sessions.clear(); + } + + /// Check if there are sessions available to resume + pub fn has_sessions(&self) -> bool { + !self.sessions.is_empty() + } + + /// Get the current working directory + pub fn cwd(&self) -> Option<&Path> { + self.cwd.as_deref() + } + + /// Render the session picker as a full-panel overlay + pub fn overlay_ui(&mut self, ui: &mut egui::Ui) -> Option<SessionPickerAction> { + let cwd = self.cwd.clone()?; + + let mut action = None; + let is_narrow = notedeck::ui::is_narrow(ui.ctx()); + let ctrl_held = ui.input(|i| i.modifiers.ctrl); + + // Handle keyboard shortcuts for sessions (1-9) + for (idx, session) in self.sessions.iter().take(9).enumerate() { + let key = match idx { + 0 => egui::Key::Num1, + 1 => egui::Key::Num2, + 2 => egui::Key::Num3, + 3 => egui::Key::Num4, + 4 => egui::Key::Num5, + 5 => egui::Key::Num6, + 6 => egui::Key::Num7, + 7 => egui::Key::Num8, + 8 => egui::Key::Num9, + _ => continue, + }; + if ui.input(|i| i.key_pressed(key)) { + return Some(SessionPickerAction::ResumeSession { + cwd, + session_id: session.session_id.clone(), + title: session.summary.clone(), + }); + } + } + + // Handle N key for new session + if ui.input(|i| i.key_pressed(egui::Key::N)) { + return Some(SessionPickerAction::NewSession { cwd }); + } + + // Handle Escape/B key to go back + if ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::B)) { + return Some(SessionPickerAction::BackToDirectoryPicker); + } + + // Full panel frame + egui::Frame::new() + .fill(ui.visuals().panel_fill) + .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20)) + .show(ui, |ui| { + // Header + ui.horizontal(|ui| { + if ui.button("< Back").clicked() { + action = Some(SessionPickerAction::BackToDirectoryPicker); + } + ui.add_space(16.0); + ui.heading("Resume Session"); + }); + + ui.add_space(8.0); + + // Show the cwd + ui.label(RichText::new(abbreviate_path(&cwd)).monospace().weak()); + + ui.add_space(16.0); + + // Centered content + let max_content_width = if is_narrow { + ui.available_width() + } else { + 600.0 + }; + let available_height = ui.available_height(); + + ui.allocate_ui_with_layout( + egui::vec2(max_content_width, available_height), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + // New session button at top + ui.horizontal(|ui| { + let new_button = egui::Button::new( + RichText::new("+ New Session").size(if is_narrow { + 16.0 + } else { + 14.0 + }), + ) + .min_size(Vec2::new( + if is_narrow { + ui.available_width() - 28.0 + } else { + 150.0 + }, + if is_narrow { 48.0 } else { 36.0 }, + )); + + let response = ui.add(new_button); + + // Show keybind hint when Ctrl is held + if ctrl_held { + let hint_center = + response.rect.right_center() + egui::vec2(14.0, 0.0); + paint_keybind_hint(ui, hint_center, "N", 18.0); + } + + if response + .on_hover_text("Start a new conversation (N)") + .clicked() + { + action = Some(SessionPickerAction::NewSession { cwd: cwd.clone() }); + } + }); + + ui.add_space(16.0); + ui.separator(); + ui.add_space(12.0); + + // Sessions list + if self.sessions.is_empty() { + ui.label( + RichText::new("No previous sessions found for this directory.") + .weak(), + ); + } else { + ui.label(RichText::new("Recent Sessions").strong()); + ui.add_space(8.0); + + let scroll_height = if is_narrow { + (ui.available_height() - 80.0).max(100.0) + } else { + 400.0 + }; + + egui::ScrollArea::vertical() + .max_height(scroll_height) + .show(ui, |ui| { + for (idx, session) in self + .sessions + .iter() + .take(MAX_SESSIONS_DISPLAYED) + .enumerate() + { + let button_height = if is_narrow { 64.0 } else { 50.0 }; + let hint_width = + if ctrl_held && idx < 9 { 24.0 } else { 0.0 }; + let button_width = ui.available_width() - hint_width - 4.0; + + ui.horizontal(|ui| { + // Create a frame for the session button + let response = ui.add( + egui::Button::new("") + .min_size(Vec2::new( + button_width, + button_height, + )) + .fill( + ui.visuals().widgets.inactive.weak_bg_fill, + ), + ); + + // Draw the content over the button + let rect = response.rect; + let painter = ui.painter(); + + // Summary text (truncated) + let summary_text = &session.summary; + let text_color = ui.visuals().text_color(); + painter.text( + rect.left_top() + egui::vec2(8.0, 8.0), + egui::Align2::LEFT_TOP, + summary_text, + egui::FontId::proportional(13.0), + text_color, + ); + + // Metadata line (time + message count) + let meta_text = format!( + "{} • {} messages", + format_relative_time(&session.last_timestamp), + session.message_count + ); + painter.text( + rect.left_bottom() + egui::vec2(8.0, -8.0), + egui::Align2::LEFT_BOTTOM, + meta_text, + egui::FontId::proportional(11.0), + ui.visuals().weak_text_color(), + ); + + // Show keybind hint when Ctrl is held + if ctrl_held && idx < 9 { + let hint_text = format!("{}", idx + 1); + let hint_center = response.rect.right_center() + + egui::vec2(hint_width / 2.0 + 2.0, 0.0); + paint_keybind_hint( + ui, + hint_center, + &hint_text, + 18.0, + ); + } + + if response.clicked() { + action = Some(SessionPickerAction::ResumeSession { + cwd: cwd.clone(), + session_id: session.session_id.clone(), + title: session.summary.clone(), + }); + } + }); + + ui.add_space(4.0); + } + + if self.sessions.len() > MAX_SESSIONS_DISPLAYED { + ui.add_space(8.0); + ui.label( + RichText::new(format!( + "... and {} more sessions", + self.sessions.len() - MAX_SESSIONS_DISPLAYED + )) + .weak(), + ); + } + }); + } + }, + ); + }); + + action + } +} + +/// Abbreviate a path for display (e.g., replace home dir with ~) +fn abbreviate_path(path: &Path) -> String { + if let Some(home) = dirs::home_dir() { + if let Ok(relative) = path.strip_prefix(&home) { + return format!("~/{}", relative.display()); + } + } + path.display().to_string() +}