diff --git a/crates/loopal-runtime/src/agent_loop/input.rs b/crates/loopal-runtime/src/agent_loop/input.rs index 01046d6..f5ed1fd 100644 --- a/crates/loopal-runtime/src/agent_loop/input.rs +++ b/crates/loopal-runtime/src/agent_loop/input.rs @@ -98,6 +98,21 @@ impl AgentLoopRunner { { error!(error = %e, "failed to persist message"); } + // Auto-generate session title from first non-ephemeral user message. + if !ephemeral && self.params.session.title.is_empty() { + let title = extract_title(&env.content.text); + if !title.is_empty() { + self.params.session.title = title; + if let Err(e) = self + .params + .deps + .session_manager + .update_session(&self.params.session) + { + error!(error = %e, "failed to persist session title"); + } + } + } self.params.store.push_user(user_msg); WaitResult::MessageAdded } @@ -124,3 +139,15 @@ enum SelectResult { Envelope(Envelope), ChannelClosed, } + +/// Extract first line of user text, truncated to 80 **characters**, for session title. +fn extract_title(text: &str) -> String { + let line = text.lines().next().unwrap_or("").trim(); + let char_count = line.chars().count(); + if char_count <= 80 { + line.to_string() + } else { + let truncated: String = line.chars().take(80).collect(); + format!("{truncated}…") + } +} diff --git a/crates/loopal-runtime/src/session.rs b/crates/loopal-runtime/src/session.rs index 8fe8e21..112dda4 100644 --- a/crates/loopal-runtime/src/session.rs +++ b/crates/loopal-runtime/src/session.rs @@ -121,6 +121,12 @@ impl SessionManager { Ok(sessions) } + /// List root (non-sub-agent) sessions for a working directory, newest first. + pub fn list_root_sessions_for_cwd(&self, cwd: &Path) -> Result> { + let sessions = self.session_store.list_root_sessions_for_cwd(cwd)?; + Ok(sessions) + } + /// List all sessions. pub fn list_sessions(&self) -> Result> { let sessions = self.session_store.list_sessions()?; diff --git a/crates/loopal-storage/src/lib.rs b/crates/loopal-storage/src/lib.rs index 56ff04d..cb7e75a 100644 --- a/crates/loopal-storage/src/lib.rs +++ b/crates/loopal-storage/src/lib.rs @@ -1,6 +1,7 @@ pub mod entry; pub mod messages; pub mod replay; +mod session_query; pub mod sessions; pub use entry::{Marker, TaggedEntry}; diff --git a/crates/loopal-storage/src/session_query.rs b/crates/loopal-storage/src/session_query.rs new file mode 100644 index 0000000..1a6f050 --- /dev/null +++ b/crates/loopal-storage/src/session_query.rs @@ -0,0 +1,71 @@ +//! Session query methods — list, filter, and search sessions. + +use std::path::Path; + +use loopal_error::StorageError; + +use super::sessions::{Session, SessionStore, normalize_cwd}; + +impl SessionStore { + /// Find the most recently updated session for a given working directory. + pub fn latest_session_for_cwd(&self, cwd: &Path) -> Result, StorageError> { + let cwd_str = normalize_cwd(cwd); + let mut sessions = self.list_sessions()?; + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(sessions.into_iter().find(|s| s.cwd == cwd_str)) + } + + /// List sessions filtered by working directory, sorted by `updated_at` (newest first). + pub fn list_sessions_for_cwd(&self, cwd: &Path) -> Result, StorageError> { + let cwd_str = normalize_cwd(cwd); + let mut sessions: Vec = self + .list_sessions()? + .into_iter() + .filter(|s| s.cwd == cwd_str) + .collect(); + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(sessions) + } + + /// List only root (non-sub-agent) sessions for a working directory. + /// Scans ALL sessions to build the exclusion set, covering cross-cwd sub-agents. + pub fn list_root_sessions_for_cwd(&self, cwd: &Path) -> Result, StorageError> { + let all = self.list_sessions()?; + let sub_ids: std::collections::HashSet = all + .iter() + .flat_map(|s| s.sub_agents.iter().map(|r| r.session_id.clone())) + .collect(); + let cwd_str = normalize_cwd(cwd); + let mut root: Vec = all + .into_iter() + .filter(|s| s.cwd == cwd_str && !sub_ids.contains(&s.id)) + .collect(); + root.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(root) + } + + /// List all sessions, sorted by creation time (newest first). + pub fn list_sessions(&self) -> Result, StorageError> { + let sessions_dir = self.sessions_dir(); + if !sessions_dir.exists() { + return Ok(Vec::new()); + } + + let mut sessions = Vec::new(); + for entry in std::fs::read_dir(&sessions_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + let session_file = entry.path().join("session.json"); + if session_file.exists() { + let contents = std::fs::read_to_string(&session_file)?; + if let Ok(session) = serde_json::from_str::(&contents) { + sessions.push(session); + } + } + } + } + + sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(sessions) + } +} diff --git a/crates/loopal-storage/src/sessions.rs b/crates/loopal-storage/src/sessions.rs index d14b840..f4e0c30 100644 --- a/crates/loopal-storage/src/sessions.rs +++ b/crates/loopal-storage/src/sessions.rs @@ -55,7 +55,7 @@ impl SessionStore { Self { base_dir } } - fn sessions_dir(&self) -> PathBuf { + pub(crate) fn sessions_dir(&self) -> PathBuf { self.base_dir.join("sessions") } @@ -137,56 +137,11 @@ impl SessionStore { } Ok(()) } - - /// Find the most recently updated session for a given working directory. - pub fn latest_session_for_cwd(&self, cwd: &Path) -> Result, StorageError> { - let cwd_str = normalize_cwd(cwd); - let mut sessions = self.list_sessions()?; - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); - Ok(sessions.into_iter().find(|s| s.cwd == cwd_str)) - } - - /// List sessions filtered by working directory, sorted by `updated_at` (newest first). - pub fn list_sessions_for_cwd(&self, cwd: &Path) -> Result, StorageError> { - let cwd_str = normalize_cwd(cwd); - let mut sessions: Vec = self - .list_sessions()? - .into_iter() - .filter(|s| s.cwd == cwd_str) - .collect(); - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); - Ok(sessions) - } - - /// List all sessions, sorted by creation time (newest first). - pub fn list_sessions(&self) -> Result, StorageError> { - let sessions_dir = self.sessions_dir(); - if !sessions_dir.exists() { - return Ok(Vec::new()); - } - - let mut sessions = Vec::new(); - for entry in std::fs::read_dir(&sessions_dir)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - let session_file = entry.path().join("session.json"); - if session_file.exists() { - let contents = std::fs::read_to_string(&session_file)?; - if let Ok(session) = serde_json::from_str::(&contents) { - sessions.push(session); - } - } - } - } - - sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - Ok(sessions) - } } /// Canonicalize a path for consistent session cwd comparison. /// Falls back to the original path if canonicalization fails (e.g. path doesn't exist yet). -fn normalize_cwd(cwd: &Path) -> String { +pub(crate) fn normalize_cwd(cwd: &Path) -> String { std::fs::canonicalize(cwd) .unwrap_or_else(|_| cwd.to_path_buf()) .to_string_lossy() diff --git a/crates/loopal-tui/src/app/types.rs b/crates/loopal-tui/src/app/types.rs index 553396c..ab4afc4 100644 --- a/crates/loopal-tui/src/app/types.rs +++ b/crates/loopal-tui/src/app/types.rs @@ -85,6 +85,8 @@ pub enum SubPage { ModelPicker(PickerState), /// Rewind picker — user selects a turn to rewind to. RewindPicker(RewindPickerState), + /// Session picker — user selects a session to resume. + SessionPicker(PickerState), } /// Which sub-panel within the panel zone is focused. diff --git a/crates/loopal-tui/src/command/resume_cmd.rs b/crates/loopal-tui/src/command/resume_cmd.rs index e8d06ef..6de94ce 100644 --- a/crates/loopal-tui/src/command/resume_cmd.rs +++ b/crates/loopal-tui/src/command/resume_cmd.rs @@ -1,14 +1,14 @@ -//! `/resume` — resume a previous session or list resumable sessions. +//! `/resume` — resume a previous session or open the session picker. //! //! With argument: hot-swap agent context to the specified session (prefix match). -//! Without argument: list recent sessions for the current project directory. +//! Without argument: open a picker sub-page listing resumable sessions. use std::path::Path; use async_trait::async_trait; use super::{CommandEffect, CommandHandler}; -use crate::app::App; +use crate::app::{App, PickerItem, PickerState, SubPage}; pub struct ResumeCmd; @@ -33,9 +33,7 @@ impl CommandHandler for ResumeCmd { } }, None => { - let text = format_project_sessions(&app.cwd) - .unwrap_or_else(|| "No previous sessions found for this project.".into()); - app.session.push_system_message(text); + open_session_picker(app); CommandEffect::Done } } @@ -44,12 +42,12 @@ impl CommandHandler for ResumeCmd { // ── Query ────────────────────────────────────────────────────────── -/// Resolve a partial ID (prefix) to the full session ID. +/// Resolve a partial ID (prefix) to the full session ID (root sessions only). fn resolve_session_id(cwd: &Path, partial: &str) -> Result { let sm = loopal_runtime::SessionManager::new() .map_err(|e| format!("Failed to access sessions: {e}"))?; let sessions = sm - .list_sessions_for_cwd(cwd) + .list_root_sessions_for_cwd(cwd) .map_err(|e| format!("Failed to list sessions: {e}"))?; let matches: Vec<_> = sessions .iter() @@ -62,30 +60,60 @@ fn resolve_session_id(cwd: &Path, partial: &str) -> Result { } } -// ── Formatting ───────────────────────────────────────────────────── +// ── Picker ───────────────────────────────────────────────────────── -fn format_project_sessions(cwd: &Path) -> Option { - let sm = loopal_runtime::SessionManager::new().ok()?; - let sessions = sm.list_sessions_for_cwd(cwd).ok()?; - if sessions.is_empty() { - return None; - } - let mut lines = Vec::with_capacity(sessions.len().min(5) + 3); - lines.push("Recent sessions for this project:".into()); - lines.push(String::new()); +fn open_session_picker(app: &mut App) { + let sm = match loopal_runtime::SessionManager::new() { + Ok(sm) => sm, + Err(_) => { + app.session + .push_system_message("Failed to access sessions.".into()); + return; + } + }; + let sessions = match sm.list_root_sessions_for_cwd(&app.cwd) { + Ok(s) => s, + Err(_) => { + app.session + .push_system_message("Failed to list sessions.".into()); + return; + } + }; + + // Exclude current session + let current_id = app.session.lock().root_session_id.clone(); + let items: Vec = sessions + .into_iter() + .filter(|s| current_id.as_deref() != Some(&s.id)) + .map(|s| { + let label = if s.title.is_empty() { + "(untitled)".to_string() + } else { + s.title + }; + let short_id = &s.id[..8.min(s.id.len())]; + let updated = s.updated_at.format("%m-%d %H:%M"); + PickerItem { + description: format!("{short_id} {updated} {}", s.model), + label, + value: s.id, + } + }) + .collect(); - for s in sessions.iter().take(5) { - let short_id = &s.id[..8]; - let updated = s.updated_at.format("%Y-%m-%d %H:%M"); - let title = if s.title.is_empty() { - String::new() - } else { - format!(" — {}", s.title) - }; - lines.push(format!(" {short_id} {updated} {}{title}", s.model)); + if items.is_empty() { + app.session + .push_system_message("No previous sessions found for this project.".into()); + return; } - lines.push(String::new()); - lines.push("To resume: /resume ".into()); - Some(lines.join("\n")) + app.sub_page = Some(SubPage::SessionPicker(PickerState { + title: "Resume Session".to_string(), + items, + filter: String::new(), + filter_cursor: 0, + selected: 0, + thinking_options: vec![], + thinking_selected: 0, + })); } diff --git a/crates/loopal-tui/src/input/actions.rs b/crates/loopal-tui/src/input/actions.rs index 9f105bd..301e4e2 100644 --- a/crates/loopal-tui/src/input/actions.rs +++ b/crates/loopal-tui/src/input/actions.rs @@ -11,6 +11,8 @@ pub enum SubPageResult { }, /// A turn was selected for rewind (turn_index from oldest = 0). RewindConfirmed(usize), + /// A session was selected to resume (full session ID). + SessionSelected(String), } /// Action resulting from input handling. diff --git a/crates/loopal-tui/src/input/mod.rs b/crates/loopal-tui/src/input/mod.rs index 9d281a0..178a8f4 100644 --- a/crates/loopal-tui/src/input/mod.rs +++ b/crates/loopal-tui/src/input/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod multiline; mod navigation; pub(crate) mod paste; mod sub_page; +mod sub_page_rewind; pub use actions::*; diff --git a/crates/loopal-tui/src/input/sub_page.rs b/crates/loopal-tui/src/input/sub_page.rs index 6b6bc12..c51e640 100644 --- a/crates/loopal-tui/src/input/sub_page.rs +++ b/crates/loopal-tui/src/input/sub_page.rs @@ -1,7 +1,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::app::{App, SubPage}; +use crate::app::{App, PickerState, SubPage}; +use super::sub_page_rewind::handle_rewind_picker_key; use super::{InputAction, SubPageResult}; /// Handle keys when a sub-page (picker) is active. All keys are consumed. @@ -23,31 +24,88 @@ pub(super) fn handle_sub_page_key(app: &mut App, key: &KeyEvent) -> InputAction match sub_page { SubPage::ModelPicker(_) => handle_model_picker_key(app, key), SubPage::RewindPicker(_) => handle_rewind_picker_key(app, key), + SubPage::SessionPicker(_) => handle_session_picker_key(app, key), } } -fn handle_model_picker_key(app: &mut App, key: &KeyEvent) -> InputAction { - let picker = match app.sub_page.as_mut().unwrap() { - SubPage::ModelPicker(p) => p, - _ => unreachable!(), - }; +// ── Generic picker navigation (Esc/Up/Down/Char/Backspace) ──────── + +/// Result of generic picker key handling. +enum PickerKeyResult { + /// Picker should be dismissed (Esc pressed). + Dismiss, + /// Key was handled (navigation / filter edit). + Handled, + /// Key not handled — caller should process (Enter, Left/Right, etc.). + Unhandled, +} + +/// Handle common PickerState keys. Does NOT touch `app.sub_page`. +fn handle_generic_picker_key(picker: &mut PickerState, key: &KeyEvent) -> PickerKeyResult { match key.code { - KeyCode::Esc => { - app.sub_page = None; - app.last_esc_time = None; // prevent stale double-ESC trigger - InputAction::None - } + KeyCode::Esc => PickerKeyResult::Dismiss, KeyCode::Up => { picker.selected = picker.selected.saturating_sub(1); - InputAction::None + PickerKeyResult::Handled } KeyCode::Down => { let count = picker.filtered_items().len(); if picker.selected + 1 < count { picker.selected += 1; } - InputAction::None + PickerKeyResult::Handled } + KeyCode::Char(c) => { + picker.filter.insert(picker.filter_cursor, c); + picker.filter_cursor += c.len_utf8(); + picker.selected = 0; + picker.clamp_selected(); + PickerKeyResult::Handled + } + KeyCode::Backspace => { + if picker.filter_cursor > 0 { + let prev = picker.filter[..picker.filter_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + picker.filter.remove(prev); + picker.filter_cursor = prev; + picker.selected = 0; + picker.clamp_selected(); + } + PickerKeyResult::Handled + } + _ => PickerKeyResult::Unhandled, + } +} + +/// Dismiss the picker overlay and reset ESC state. +fn dismiss_picker(app: &mut App) { + app.sub_page = None; + app.last_esc_time = None; +} + +// ── Model picker ────────────────────────────────────────────────── + +fn handle_model_picker_key(app: &mut App, key: &KeyEvent) -> InputAction { + let picker = match app.sub_page.as_mut().unwrap() { + SubPage::ModelPicker(p) => p, + _ => unreachable!(), + }; + match handle_generic_picker_key(picker, key) { + PickerKeyResult::Dismiss => { + dismiss_picker(app); + return InputAction::None; + } + PickerKeyResult::Handled => return InputAction::None, + PickerKeyResult::Unhandled => {} + } + let picker = match app.sub_page.as_mut().unwrap() { + SubPage::ModelPicker(p) => p, + _ => unreachable!(), + }; + match key.code { KeyCode::Enter => { let filtered = picker.filtered_items(); if let Some(item) = filtered.get(picker.selected) { @@ -56,8 +114,7 @@ fn handle_model_picker_key(app: &mut App, key: &KeyEvent) -> InputAction { .thinking_options .get(picker.thinking_selected) .map(|o| o.value.clone()); - app.sub_page = None; - app.last_esc_time = None; + dismiss_picker(app); match thinking_json { Some(json) => { InputAction::SubPageConfirm(SubPageResult::ModelAndThinkingSelected { @@ -89,63 +146,40 @@ fn handle_model_picker_key(app: &mut App, key: &KeyEvent) -> InputAction { } InputAction::None } - KeyCode::Char(c) => { - picker.filter.insert(picker.filter_cursor, c); - picker.filter_cursor += c.len_utf8(); - picker.selected = 0; - picker.clamp_selected(); - InputAction::None - } - KeyCode::Backspace => { - if picker.filter_cursor > 0 { - let prev = picker.filter[..picker.filter_cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - picker.filter.remove(prev); - picker.filter_cursor = prev; - picker.selected = 0; - picker.clamp_selected(); - } - InputAction::None - } _ => InputAction::None, } } -fn handle_rewind_picker_key(app: &mut App, key: &KeyEvent) -> InputAction { - let state = match app.sub_page.as_mut().unwrap() { - SubPage::RewindPicker(s) => s, +// ── Session picker ──────────────────────────────────────────────── + +fn handle_session_picker_key(app: &mut App, key: &KeyEvent) -> InputAction { + let picker = match app.sub_page.as_mut().unwrap() { + SubPage::SessionPicker(p) => p, _ => unreachable!(), }; - match key.code { - KeyCode::Esc => { - app.sub_page = None; - app.last_esc_time = None; - InputAction::None - } - KeyCode::Up => { - state.selected = state.selected.saturating_sub(1); - InputAction::None + match handle_generic_picker_key(picker, key) { + PickerKeyResult::Dismiss => { + dismiss_picker(app); + return InputAction::None; } - KeyCode::Down => { - if state.selected + 1 < state.turns.len() { - state.selected += 1; - } + PickerKeyResult::Handled => return InputAction::None, + PickerKeyResult::Unhandled => {} + } + let picker = match app.sub_page.as_mut().unwrap() { + SubPage::SessionPicker(p) => p, + _ => unreachable!(), + }; + if key.code == KeyCode::Enter { + let filtered = picker.filtered_items(); + if let Some(item) = filtered.get(picker.selected) { + let session_id = item.value.clone(); + dismiss_picker(app); + InputAction::SubPageConfirm(SubPageResult::SessionSelected(session_id)) + } else { + app.sub_page = None; InputAction::None } - KeyCode::Enter => { - if let Some(item) = state.turns.get(state.selected) { - let turn_index = item.turn_index; - app.sub_page = None; - app.last_esc_time = None; - InputAction::SubPageConfirm(SubPageResult::RewindConfirmed(turn_index)) - } else { - app.sub_page = None; - InputAction::None - } - } - _ => InputAction::None, + } else { + InputAction::None } } diff --git a/crates/loopal-tui/src/input/sub_page_rewind.rs b/crates/loopal-tui/src/input/sub_page_rewind.rs new file mode 100644 index 0000000..1f9fc1f --- /dev/null +++ b/crates/loopal-tui/src/input/sub_page_rewind.rs @@ -0,0 +1,43 @@ +//! Rewind picker key handling (sub-page). + +use crossterm::event::{KeyCode, KeyEvent}; + +use crate::app::{App, SubPage}; + +use super::{InputAction, SubPageResult}; + +pub(super) fn handle_rewind_picker_key(app: &mut App, key: &KeyEvent) -> InputAction { + let state = match app.sub_page.as_mut().unwrap() { + SubPage::RewindPicker(s) => s, + _ => unreachable!(), + }; + match key.code { + KeyCode::Esc => { + app.sub_page = None; + app.last_esc_time = None; + InputAction::None + } + KeyCode::Up => { + state.selected = state.selected.saturating_sub(1); + InputAction::None + } + KeyCode::Down => { + if state.selected + 1 < state.turns.len() { + state.selected += 1; + } + InputAction::None + } + KeyCode::Enter => { + if let Some(item) = state.turns.get(state.selected) { + let turn_index = item.turn_index; + app.sub_page = None; + app.last_esc_time = None; + InputAction::SubPageConfirm(SubPageResult::RewindConfirmed(turn_index)) + } else { + app.sub_page = None; + InputAction::None + } + } + _ => InputAction::None, + } +} diff --git a/crates/loopal-tui/src/key_dispatch_ops.rs b/crates/loopal-tui/src/key_dispatch_ops.rs index 0a02dc5..d5094f9 100644 --- a/crates/loopal-tui/src/key_dispatch_ops.rs +++ b/crates/loopal-tui/src/key_dispatch_ops.rs @@ -66,6 +66,9 @@ pub(crate) async fn handle_sub_page_confirm(app: &mut App, result: SubPageResult SubPageResult::RewindConfirmed(turn_index) => { app.session.rewind(turn_index).await; } + SubPageResult::SessionSelected(session_id) => { + app.session.resume_session(&session_id).await; + } } } diff --git a/crates/loopal-tui/src/render.rs b/crates/loopal-tui/src/render.rs index 658bf0d..f2a5c9c 100644 --- a/crates/loopal-tui/src/render.rs +++ b/crates/loopal-tui/src/render.rs @@ -38,7 +38,9 @@ pub fn draw(f: &mut Frame, app: &mut App) { // Sub-page mode: picker replaces f₁..f₄, only f₅ remains if let Some(ref sub_page) = app.sub_page { match sub_page { - SubPage::ModelPicker(p) => views::picker::render_picker(f, p, layout.picker), + SubPage::ModelPicker(p) | SubPage::SessionPicker(p) => { + views::picker::render_picker(f, p, layout.picker); + } SubPage::RewindPicker(r) => { views::rewind_picker::render_rewind_picker(f, r, layout.picker); }