Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions crates/loopal-runtime/src/agent_loop/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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}…")
}
}
6 changes: 6 additions & 0 deletions crates/loopal-runtime/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<Session>> {
let sessions = self.session_store.list_root_sessions_for_cwd(cwd)?;
Ok(sessions)
}

/// List all sessions.
pub fn list_sessions(&self) -> Result<Vec<Session>> {
let sessions = self.session_store.list_sessions()?;
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-storage/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod entry;
pub mod messages;
pub mod replay;
mod session_query;
pub mod sessions;

pub use entry::{Marker, TaggedEntry};
Expand Down
71 changes: 71 additions & 0 deletions crates/loopal-storage/src/session_query.rs
Original file line number Diff line number Diff line change
@@ -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<Option<Session>, 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<Vec<Session>, StorageError> {
let cwd_str = normalize_cwd(cwd);
let mut sessions: Vec<Session> = 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<Vec<Session>, StorageError> {
let all = self.list_sessions()?;
let sub_ids: std::collections::HashSet<String> = 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<Session> = 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<Vec<Session>, 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::<Session>(&contents) {
sessions.push(session);
}
}
}
}

sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(sessions)
}
}
49 changes: 2 additions & 47 deletions crates/loopal-storage/src/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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<Option<Session>, 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<Vec<Session>, StorageError> {
let cwd_str = normalize_cwd(cwd);
let mut sessions: Vec<Session> = 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<Vec<Session>, 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::<Session>(&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()
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-tui/src/app/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
88 changes: 58 additions & 30 deletions crates/loopal-tui/src/command/resume_cmd.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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
}
}
Expand All @@ -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<String, String> {
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()
Expand All @@ -62,30 +60,60 @@ fn resolve_session_id(cwd: &Path, partial: &str) -> Result<String, String> {
}
}

// ── Formatting ─────────────────────────────────────────────────────
// ── Picker ─────────────────────────────────────────────────────────

fn format_project_sessions(cwd: &Path) -> Option<String> {
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<PickerItem> = 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 <ID>".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,
}));
}
2 changes: 2 additions & 0 deletions crates/loopal-tui/src/input/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-tui/src/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub(crate) mod multiline;
mod navigation;
pub(crate) mod paste;
mod sub_page;
mod sub_page_rewind;

pub use actions::*;

Expand Down
Loading
Loading