From 6b8774f0aa9576e45df66069e5d1d94425a18386 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 2 Oct 2025 01:33:49 +0200 Subject: [PATCH] Add session persistence and browser functionality - Implement `StorageManager` for saving, loading, and managing sessions. - Introduce platform-specific session directories for persistence. - Add session browser UI for listing, loading, and deleting saved sessions. - Enable AI-generated descriptions for session summaries. - Update configurations to support storage settings and description generation. - Extend README and tests to document and validate new functionality. --- README.md | 32 ++- crates/owlen-core/Cargo.toml | 2 + crates/owlen-core/src/config.rs | 73 +++++- crates/owlen-core/src/conversation.rs | 33 ++- crates/owlen-core/src/lib.rs | 4 + crates/owlen-core/src/provider.rs | 5 +- crates/owlen-core/src/session.rs | 84 +++++++ crates/owlen-core/src/storage.rs | 308 ++++++++++++++++++++++++++ crates/owlen-core/src/types.rs | 4 + crates/owlen-core/src/ui.rs | 2 + crates/owlen-tui/src/chat_app.rs | 143 +++++++++++- crates/owlen-tui/src/ui.rs | 154 ++++++++++++- 12 files changed, 818 insertions(+), 26 deletions(-) create mode 100644 crates/owlen-core/src/storage.rs diff --git a/README.md b/README.md index 5588fe0..d921725 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ The OWLEN interface features a clean, multi-panel layout with vim-inspired navig - **Visual Selection & Clipboard** - Yank/paste text across panels - **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation - **Model Management** - Interactive model and provider selection (press `m`) -- **Session Management** - Start new conversations, clear history +- **Session Persistence** - Save and load conversations to/from disk +- **AI-Generated Descriptions** - Automatic short summaries for saved sessions +- **Session Management** - Start new conversations, clear history, browse saved sessions - **Thinking Mode Support** - Dedicated panel for extended reasoning content - **Bracketed Paste** - Safe paste handling for multi-line content @@ -139,6 +141,15 @@ cargo build --release --bin owlen-code --features code-client - `:m` / `:model` - Open model selector - `:n` / `:new` - Start new conversation - `:h` / `:help` - Show help +- `:save [name]` / `:w [name]` - Save current conversation +- `:load` / `:open` - Browse and load saved sessions +- `:sessions` / `:ls` - List saved sessions + +**Session Browser** (accessed via `:load` or `:sessions`): +- `j` / `k` / `↑` / `↓` - Navigate sessions +- `Enter` - Load selected session +- `d` - Delete selected session +- `Esc` - Close browser ### Panel Management - Three panels: Chat, Thinking, and Input @@ -164,6 +175,22 @@ base_url = "http://localhost:11434" timeout = 300 ``` +### Storage Settings + +Sessions are saved to platform-specific directories by default: +- **Linux**: `~/.local/share/owlen/sessions` +- **Windows**: `%APPDATA%\owlen\sessions` +- **macOS**: `~/Library/Application Support/owlen/sessions` + +You can customize this in your config: + +```toml +[storage] +# conversation_dir = "~/custom/path" # Optional: override default location +max_saved_sessions = 25 +generate_descriptions = true # AI-generated summaries for saved sessions +``` + Configuration is automatically saved when you change models or providers. ## Repository Layout @@ -226,9 +253,10 @@ cargo fmt - [x] Bracketed paste support ### In Progress +- [x] Session persistence (save/load conversations) - [ ] Theming options and color customization - [ ] Enhanced configuration UX (in-app settings) -- [ ] Chat history management (save/load/export) +- [ ] Conversation export (Markdown, JSON, plain text) ### Planned - [ ] Code Client Enhancement diff --git a/crates/owlen-core/Cargo.toml b/crates/owlen-core/Cargo.toml index 56cbf4a..a1684c8 100644 --- a/crates/owlen-core/Cargo.toml +++ b/crates/owlen-core/Cargo.toml @@ -23,6 +23,8 @@ futures = "0.3.28" async-trait = "0.1.73" toml = "0.8.0" shellexpand = "3.1.0" +dirs = "5.0" [dev-dependencies] tokio-test = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index 9be1689..fe71e42 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -238,18 +238,20 @@ impl Default for UiSettings { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageSettings { #[serde(default = "StorageSettings::default_conversation_dir")] - pub conversation_dir: String, + pub conversation_dir: Option, #[serde(default = "StorageSettings::default_auto_save")] pub auto_save_sessions: bool, #[serde(default = "StorageSettings::default_max_sessions")] pub max_saved_sessions: usize, #[serde(default = "StorageSettings::default_session_timeout")] pub session_timeout_minutes: u64, + #[serde(default = "StorageSettings::default_generate_descriptions")] + pub generate_descriptions: bool, } impl StorageSettings { - fn default_conversation_dir() -> String { - "~/.local/share/owlen/conversations".to_string() + fn default_conversation_dir() -> Option { + None } fn default_auto_save() -> bool { @@ -264,19 +266,35 @@ impl StorageSettings { 120 } + fn default_generate_descriptions() -> bool { + true + } + /// Resolve storage directory path + /// Uses platform-specific data directory if not explicitly configured: + /// - Linux: ~/.local/share/owlen/sessions + /// - Windows: %APPDATA%\owlen\sessions + /// - macOS: ~/Library/Application Support/owlen/sessions pub fn conversation_path(&self) -> PathBuf { - PathBuf::from(shellexpand::tilde(&self.conversation_dir).as_ref()) + if let Some(ref dir) = self.conversation_dir { + PathBuf::from(shellexpand::tilde(dir).as_ref()) + } else { + // Use platform-specific data directory + dirs::data_local_dir() + .map(|d| d.join("owlen").join("sessions")) + .unwrap_or_else(|| PathBuf::from("./owlen_sessions")) + } } } impl Default for StorageSettings { fn default() -> Self { Self { - conversation_dir: Self::default_conversation_dir(), + conversation_dir: None, // Use platform-specific defaults auto_save_sessions: Self::default_auto_save(), max_saved_sessions: Self::default_max_sessions(), session_timeout_minutes: Self::default_session_timeout(), + generate_descriptions: Self::default_generate_descriptions(), } } } @@ -340,3 +358,48 @@ pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig { pub fn session_timeout(config: &Config) -> Duration { Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_storage_platform_specific_paths() { + let config = Config::default(); + let path = config.storage.conversation_path(); + + // Verify it contains owlen/sessions + assert!(path.to_string_lossy().contains("owlen")); + assert!(path.to_string_lossy().contains("sessions")); + + // Platform-specific checks + #[cfg(target_os = "linux")] + { + // Linux should use ~/.local/share/owlen/sessions + assert!(path.to_string_lossy().contains(".local/share")); + } + + #[cfg(target_os = "windows")] + { + // Windows should use AppData + assert!(path.to_string_lossy().contains("AppData")); + } + + #[cfg(target_os = "macos")] + { + // macOS should use ~/Library/Application Support + assert!(path.to_string_lossy().contains("Library/Application Support")); + } + + println!("Config conversation path: {}", path.display()); + } + + #[test] + fn test_storage_custom_path() { + let mut config = Config::default(); + config.storage.conversation_dir = Some("~/custom/path".to_string()); + + let path = config.storage.conversation_path(); + assert!(path.to_string_lossy().contains("custom/path")); + } +} diff --git a/crates/owlen-core/src/conversation.rs b/crates/owlen-core/src/conversation.rs index d69664a..7888ff2 100644 --- a/crates/owlen-core/src/conversation.rs +++ b/crates/owlen-core/src/conversation.rs @@ -1,7 +1,9 @@ +use crate::storage::StorageManager; use crate::types::{Conversation, Message}; use crate::Result; use serde_json::{Number, Value}; use std::collections::{HashMap, VecDeque}; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use uuid::Uuid; @@ -47,8 +49,8 @@ impl ConversationManager { &self.active } - /// Mutable access to the active conversation (auto refreshing indexes afterwards) - fn active_mut(&mut self) -> &mut Conversation { + /// Public mutable access to the active conversation + pub fn active_mut(&mut self) -> &mut Conversation { &mut self.active } @@ -264,6 +266,33 @@ impl ConversationManager { fn stream_reset(&mut self) { self.streaming.clear(); } + + /// Save the active conversation to disk + pub fn save_active(&self, storage: &StorageManager, name: Option) -> Result { + storage.save_conversation(&self.active, name) + } + + /// Save the active conversation to disk with a description + pub fn save_active_with_description( + &self, + storage: &StorageManager, + name: Option, + description: Option + ) -> Result { + storage.save_conversation_with_description(&self.active, name, description) + } + + /// Load a conversation from disk and make it active + pub fn load_from_disk(&mut self, storage: &StorageManager, path: impl AsRef) -> Result<()> { + let conversation = storage.load_conversation(path)?; + self.load(conversation); + Ok(()) + } + + /// List all saved sessions + pub fn list_saved_sessions(storage: &StorageManager) -> Result> { + storage.list_sessions() + } } impl StreamingMetadata { diff --git a/crates/owlen-core/src/lib.rs b/crates/owlen-core/src/lib.rs index 7fb1888..a4f3779 100644 --- a/crates/owlen-core/src/lib.rs +++ b/crates/owlen-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod model; pub mod provider; pub mod router; pub mod session; +pub mod storage; pub mod types; pub mod ui; pub mod wrap_cursor; @@ -54,6 +55,9 @@ pub enum Error { #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), + #[error("Storage error: {0}")] + Storage(String), + #[error("Unknown error: {0}")] Unknown(String), } diff --git a/crates/owlen-core/src/provider.rs b/crates/owlen-core/src/provider.rs index ebc5460..015befd 100644 --- a/crates/owlen-core/src/provider.rs +++ b/crates/owlen-core/src/provider.rs @@ -87,9 +87,8 @@ impl ProviderRegistry { for provider in self.providers.values() { match provider.list_models().await { Ok(mut models) => all_models.append(&mut models), - Err(e) => { - // Log error but continue with other providers - eprintln!("Failed to get models from {}: {}", provider.name(), e); + Err(_) => { + // Continue with other providers } } } diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index 3855db7..c2d7916 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -218,4 +218,88 @@ impl SessionController { pub fn clear(&mut self) { self.conversation.clear(); } + + /// Generate a short AI description for the current conversation + pub async fn generate_conversation_description(&self) -> Result { + let conv = self.conversation.active(); + + // If conversation is empty or very short, return a simple description + if conv.messages.is_empty() { + return Ok("Empty conversation".to_string()); + } + + if conv.messages.len() == 1 { + let first_msg = &conv.messages[0]; + let preview = first_msg.content.chars().take(50).collect::(); + return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })); + } + + // Build a summary prompt from the first few and last few messages + let mut summary_messages = Vec::new(); + + // Add system message to guide the description + summary_messages.push(crate::types::Message::system( + "Summarize this conversation in 1-2 short sentences (max 100 characters). \ + Focus on the main topic or question being discussed. Be concise and descriptive.".to_string() + )); + + // Include first message + if let Some(first) = conv.messages.first() { + summary_messages.push(first.clone()); + } + + // Include a middle message if conversation is long enough + if conv.messages.len() > 4 { + if let Some(mid) = conv.messages.get(conv.messages.len() / 2) { + summary_messages.push(mid.clone()); + } + } + + // Include last message + if let Some(last) = conv.messages.last() { + if conv.messages.len() > 1 { + summary_messages.push(last.clone()); + } + } + + // Create a summarization request + let request = crate::types::ChatRequest { + model: conv.model.clone(), + messages: summary_messages, + parameters: crate::types::ChatParameters { + temperature: Some(0.3), // Lower temperature for more focused summaries + max_tokens: Some(50), // Keep it short + stream: false, + extra: std::collections::HashMap::new(), + }, + }; + + // Get the summary from the provider + match self.provider.chat(request).await { + Ok(response) => { + let description = response.message.content.trim().to_string(); + + // If description is empty, use fallback + if description.is_empty() { + let first_msg = &conv.messages[0]; + let preview = first_msg.content.chars().take(50).collect::(); + return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })); + } + + // Truncate if too long + let truncated = if description.len() > 100 { + format!("{}...", description.chars().take(97).collect::()) + } else { + description + }; + Ok(truncated) + } + Err(_e) => { + // Fallback to simple description if AI generation fails + let first_msg = &conv.messages[0]; + let preview = first_msg.content.chars().take(50).collect::(); + Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })) + } + } + } } diff --git a/crates/owlen-core/src/storage.rs b/crates/owlen-core/src/storage.rs new file mode 100644 index 0000000..113573e --- /dev/null +++ b/crates/owlen-core/src/storage.rs @@ -0,0 +1,308 @@ +//! Session persistence and storage management + +use crate::types::Conversation; +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// Metadata about a saved session +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMeta { + /// Session file path + pub path: PathBuf, + /// Conversation ID + pub id: uuid::Uuid, + /// Optional session name + pub name: Option, + /// Optional AI-generated description + pub description: Option, + /// Number of messages in the conversation + pub message_count: usize, + /// Model used + pub model: String, + /// When the session was created + pub created_at: SystemTime, + /// When the session was last updated + pub updated_at: SystemTime, +} + +/// Storage manager for persisting conversations +pub struct StorageManager { + sessions_dir: PathBuf, +} + +impl StorageManager { + /// Create a new storage manager with the default sessions directory + pub fn new() -> Result { + let sessions_dir = Self::default_sessions_dir()?; + Self::with_directory(sessions_dir) + } + + /// Create a storage manager with a custom sessions directory + pub fn with_directory(sessions_dir: PathBuf) -> Result { + // Ensure the directory exists + if !sessions_dir.exists() { + fs::create_dir_all(&sessions_dir).map_err(|e| { + Error::Storage(format!( + "Failed to create sessions directory: {}", + e + )) + })?; + } + + Ok(Self { sessions_dir }) + } + + /// Get the default sessions directory + /// - Linux: ~/.local/share/owlen/sessions + /// - Windows: %APPDATA%\owlen\sessions + /// - macOS: ~/Library/Application Support/owlen/sessions + pub fn default_sessions_dir() -> Result { + let data_dir = dirs::data_local_dir() + .ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?; + Ok(data_dir.join("owlen").join("sessions")) + } + + /// Save a conversation to disk + pub fn save_conversation(&self, conversation: &Conversation, name: Option) -> Result { + self.save_conversation_with_description(conversation, name, None) + } + + /// Save a conversation to disk with an optional description + pub fn save_conversation_with_description( + &self, + conversation: &Conversation, + name: Option, + description: Option + ) -> Result { + let filename = if let Some(ref session_name) = name { + // Use provided name, sanitized + let sanitized = sanitize_filename(session_name); + format!("{}_{}.json", conversation.id, sanitized) + } else { + // Use conversation ID and timestamp + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("{}_{}.json", conversation.id, timestamp) + }; + + let path = self.sessions_dir.join(filename); + + // Create a saveable version with the name and description + let mut save_conv = conversation.clone(); + if name.is_some() { + save_conv.name = name; + } + if description.is_some() { + save_conv.description = description; + } + + let json = serde_json::to_string_pretty(&save_conv).map_err(|e| { + Error::Storage(format!("Failed to serialize conversation: {}", e)) + })?; + + fs::write(&path, json).map_err(|e| { + Error::Storage(format!("Failed to write session file: {}", e)) + })?; + + Ok(path) + } + + /// Load a conversation from disk + pub fn load_conversation(&self, path: impl AsRef) -> Result { + let content = fs::read_to_string(path.as_ref()).map_err(|e| { + Error::Storage(format!("Failed to read session file: {}", e)) + })?; + + let conversation: Conversation = serde_json::from_str(&content).map_err(|e| { + Error::Storage(format!("Failed to parse session file: {}", e)) + })?; + + Ok(conversation) + } + + /// List all saved sessions with metadata + pub fn list_sessions(&self) -> Result> { + let mut sessions = Vec::new(); + + let entries = fs::read_dir(&self.sessions_dir).map_err(|e| { + Error::Storage(format!("Failed to read sessions directory: {}", e)) + })?; + + for entry in entries { + let entry = entry.map_err(|e| { + Error::Storage(format!("Failed to read directory entry: {}", e)) + })?; + + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + + // Try to load the conversation to extract metadata + match self.load_conversation(&path) { + Ok(conv) => { + sessions.push(SessionMeta { + path: path.clone(), + id: conv.id, + name: conv.name.clone(), + description: conv.description.clone(), + message_count: conv.messages.len(), + model: conv.model.clone(), + created_at: conv.created_at, + updated_at: conv.updated_at, + }); + } + Err(_) => { + // Skip files that can't be parsed + continue; + } + } + } + + // Sort by updated_at, most recent first + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + + Ok(sessions) + } + + /// Delete a saved session + pub fn delete_session(&self, path: impl AsRef) -> Result<()> { + fs::remove_file(path.as_ref()).map_err(|e| { + Error::Storage(format!("Failed to delete session file: {}", e)) + }) + } + + /// Get the sessions directory path + pub fn sessions_dir(&self) -> &Path { + &self.sessions_dir + } +} + +impl Default for StorageManager { + fn default() -> Self { + Self::new().expect("Failed to create default storage manager") + } +} + +/// Sanitize a filename by removing invalid characters +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '_' || c == '-' { + c + } else if c.is_whitespace() { + '_' + } else { + '-' + } + }) + .collect::() + .chars() + .take(50) // Limit length + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Message; + use tempfile::TempDir; + + #[test] + fn test_platform_specific_default_path() { + let path = StorageManager::default_sessions_dir().unwrap(); + + // Verify it contains owlen/sessions + assert!(path.to_string_lossy().contains("owlen")); + assert!(path.to_string_lossy().contains("sessions")); + + // Platform-specific checks + #[cfg(target_os = "linux")] + { + // Linux should use ~/.local/share/owlen/sessions + assert!(path.to_string_lossy().contains(".local/share")); + } + + #[cfg(target_os = "windows")] + { + // Windows should use AppData + assert!(path.to_string_lossy().contains("AppData")); + } + + #[cfg(target_os = "macos")] + { + // macOS should use ~/Library/Application Support + assert!(path.to_string_lossy().contains("Library/Application Support")); + } + + println!("Default sessions directory: {}", path.display()); + } + + #[test] + fn test_sanitize_filename() { + assert_eq!(sanitize_filename("Hello World"), "Hello_World"); + assert_eq!(sanitize_filename("test/path\\file"), "test-path-file"); + assert_eq!(sanitize_filename("file:name?"), "file-name-"); + } + + #[test] + fn test_save_and_load_conversation() { + let temp_dir = TempDir::new().unwrap(); + let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap(); + + let mut conv = Conversation::new("test-model".to_string()); + conv.messages.push(Message::user("Hello".to_string())); + conv.messages.push(Message::assistant("Hi there!".to_string())); + + // Save conversation + let path = storage.save_conversation(&conv, Some("test_session".to_string())).unwrap(); + assert!(path.exists()); + + // Load conversation + let loaded = storage.load_conversation(&path).unwrap(); + assert_eq!(loaded.id, conv.id); + assert_eq!(loaded.model, conv.model); + assert_eq!(loaded.messages.len(), 2); + assert_eq!(loaded.name, Some("test_session".to_string())); + } + + #[test] + fn test_list_sessions() { + let temp_dir = TempDir::new().unwrap(); + let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap(); + + // Create multiple sessions + for i in 0..3 { + let mut conv = Conversation::new("test-model".to_string()); + conv.messages.push(Message::user(format!("Message {}", i))); + storage.save_conversation(&conv, Some(format!("session_{}", i))).unwrap(); + } + + // List sessions + let sessions = storage.list_sessions().unwrap(); + assert_eq!(sessions.len(), 3); + + // Check that sessions are sorted by updated_at (most recent first) + for i in 0..sessions.len() - 1 { + assert!(sessions[i].updated_at >= sessions[i + 1].updated_at); + } + } + + #[test] + fn test_delete_session() { + let temp_dir = TempDir::new().unwrap(); + let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap(); + + let conv = Conversation::new("test-model".to_string()); + let path = storage.save_conversation(&conv, None).unwrap(); + assert!(path.exists()); + + storage.delete_session(&path).unwrap(); + assert!(!path.exists()); + } +} diff --git a/crates/owlen-core/src/types.rs b/crates/owlen-core/src/types.rs index 74ffd47..8babb9a 100644 --- a/crates/owlen-core/src/types.rs +++ b/crates/owlen-core/src/types.rs @@ -50,6 +50,9 @@ pub struct Conversation { pub id: Uuid, /// Optional name/title for the conversation pub name: Option, + /// Optional AI-generated description of the conversation + #[serde(default)] + pub description: Option, /// Messages in chronological order pub messages: Vec, /// Model used for this conversation @@ -167,6 +170,7 @@ impl Conversation { Self { id: Uuid::new_v4(), name: None, + description: None, messages: Vec::new(), model, created_at: now, diff --git a/crates/owlen-core/src/ui.rs b/crates/owlen-core/src/ui.rs index 33674e3..a7f257f 100644 --- a/crates/owlen-core/src/ui.rs +++ b/crates/owlen-core/src/ui.rs @@ -22,6 +22,7 @@ pub enum InputMode { Help, Visual, Command, + SessionBrowser, } impl fmt::Display for InputMode { @@ -34,6 +35,7 @@ impl fmt::Display for InputMode { InputMode::Help => "Help", InputMode::Visual => "Visual", InputMode::Command => "Command", + InputMode::SessionBrowser => "Sessions", }; f.write_str(label) } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 45d0545..5a27040 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -1,6 +1,7 @@ use anyhow::Result; use owlen_core::{ session::{SessionController, SessionOutcome}, + storage::{SessionMeta, StorageManager}, types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, ui::{AppState, AutoScroll, FocusedPanel, InputMode}, }; @@ -55,6 +56,10 @@ pub struct ChatApp { focused_panel: FocusedPanel, // Currently focused panel for scrolling chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col) thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col) + storage: StorageManager, // Storage manager for session persistence + saved_sessions: Vec, // Cached list of saved sessions + selected_session_index: usize, // Index of selected session in browser + save_name_buffer: String, // Buffer for entering save name } impl ChatApp { @@ -63,6 +68,12 @@ impl ChatApp { let mut textarea = TextArea::default(); configure_textarea_defaults(&mut textarea); + let storage = StorageManager::new().unwrap_or_else(|e| { + eprintln!("Warning: Failed to initialize storage: {}", e); + StorageManager::with_directory(std::path::PathBuf::from("/tmp/owlen_sessions")) + .expect("Failed to create fallback storage") + }); + let app = Self { controller, mode: InputMode::Normal, @@ -93,6 +104,10 @@ impl ChatApp { focused_panel: FocusedPanel::Input, chat_cursor: (0, 0), thinking_cursor: (0, 0), + storage, + saved_sessions: Vec::new(), + selected_session_index: 0, + save_name_buffer: String::new(), }; (app, session_rx) @@ -209,6 +224,14 @@ impl ChatApp { self.thinking_cursor } + pub fn saved_sessions(&self) -> &[SessionMeta] { + &self.saved_sessions + } + + pub fn selected_session_index(&self) -> usize { + self.selected_session_index + } + pub fn cycle_focus_forward(&mut self) { self.focused_panel = match self.focused_panel { FocusedPanel::Chat => { @@ -976,7 +999,11 @@ impl ChatApp { (KeyCode::Enter, _) => { // Execute command let cmd = self.command_buffer.trim(); - match cmd { + let parts: Vec<&str> = cmd.split_whitespace().collect(); + let command = parts.first().copied().unwrap_or(""); + let args = &parts[1..]; + + match command { "q" | "quit" => { return Ok(AppState::Quit); } @@ -984,9 +1011,65 @@ impl ChatApp { self.controller.clear(); self.status = "Conversation cleared".to_string(); } - "w" | "write" => { - // Could implement saving conversation here - self.status = "Conversation saved".to_string(); + "w" | "write" | "save" => { + // Save current conversation with AI-generated description + let name = if !args.is_empty() { + Some(args.join(" ")) + } else { + None + }; + + // Generate description if enabled in config + let description = if self.controller.config().storage.generate_descriptions { + self.status = "Generating description...".to_string(); + match self.controller.generate_conversation_description().await { + Ok(desc) => Some(desc), + Err(_) => None, + } + } else { + None + }; + + // Save the conversation with description + match self.controller.conversation_mut().save_active_with_description(&self.storage, name.clone(), description) { + Ok(path) => { + self.status = format!("Session saved: {}", path.display()); + self.error = None; + } + Err(e) => { + self.error = Some(format!("Failed to save session: {}", e)); + } + } + } + "load" | "open" => { + // Load saved sessions and enter browser mode + match self.storage.list_sessions() { + Ok(sessions) => { + self.saved_sessions = sessions; + self.selected_session_index = 0; + self.mode = InputMode::SessionBrowser; + self.command_buffer.clear(); + return Ok(AppState::Running); + } + Err(e) => { + self.error = Some(format!("Failed to list sessions: {}", e)); + } + } + } + "sessions" | "ls" => { + // List saved sessions + match self.storage.list_sessions() { + Ok(sessions) => { + self.saved_sessions = sessions; + self.selected_session_index = 0; + self.mode = InputMode::SessionBrowser; + self.command_buffer.clear(); + return Ok(AppState::Running); + } + Err(e) => { + self.error = Some(format!("Failed to list sessions: {}", e)); + } + } } "h" | "help" => { self.mode = InputMode::Help; @@ -1097,6 +1180,56 @@ impl ChatApp { } _ => {} }, + InputMode::SessionBrowser => match key.code { + KeyCode::Esc => { + self.mode = InputMode::Normal; + } + KeyCode::Enter => { + // Load selected session + if let Some(session) = self.saved_sessions.get(self.selected_session_index) { + match self.controller.conversation_mut().load_from_disk(&self.storage, &session.path) { + Ok(_) => { + self.status = format!("Loaded session: {}", session.name.as_deref().unwrap_or("Unnamed")); + self.error = None; + // Update thinking panel + self.update_thinking_from_last_message(); + } + Err(e) => { + self.error = Some(format!("Failed to load session: {}", e)); + } + } + } + self.mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if self.selected_session_index > 0 { + self.selected_session_index -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.selected_session_index + 1 < self.saved_sessions.len() { + self.selected_session_index += 1; + } + } + KeyCode::Char('d') => { + // Delete selected session + if let Some(session) = self.saved_sessions.get(self.selected_session_index) { + match self.storage.delete_session(&session.path) { + Ok(_) => { + self.saved_sessions.remove(self.selected_session_index); + if self.selected_session_index >= self.saved_sessions.len() && !self.saved_sessions.is_empty() { + self.selected_session_index = self.saved_sessions.len() - 1; + } + self.status = "Session deleted".to_string(); + } + Err(e) => { + self.error = Some(format!("Failed to delete session: {}", e)); + } + } + } + } + _ => {} + }, }, _ => {} } @@ -1342,7 +1475,7 @@ impl ChatApp { stream, }) => { // Step 3: Model loaded, now generating response - self.status = "Generating response...".to_string(); + self.status = format!("Model loaded. Generating response... (streaming)"); self.spawn_stream(response_id, stream); match self.controller.mark_stream_placeholder(response_id, "▌") { diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 4ba86af..7ce8b3e 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -81,6 +81,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { InputMode::ProviderSelection => render_provider_selector(frame, app), InputMode::ModelSelection => render_model_selector(frame, app), InputMode::Help => render_help(frame), + InputMode::SessionBrowser => render_session_browser(frame, app), _ => {} } } @@ -943,14 +944,13 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { InputMode::Help => (" HELP", Color::LightMagenta), InputMode::Visual => (" VISUAL", Color::Magenta), InputMode::Command => (" COMMAND", Color::Yellow), + InputMode::SessionBrowser => (" SESSIONS", Color::Yellow), }; - let status_message = if app.streaming_count() > 0 { - format!("Streaming... ({})", app.streaming_count()) - } else if let Some(error) = app.error_message() { + let status_message = if let Some(error) = app.error_message() { format!("Error: {}", error) } else { - "Ready".to_string() + app.status_message().to_string() }; let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit"; @@ -1145,11 +1145,13 @@ fn render_help(frame: &mut Frame<'_>) { Line::from(" v / Esc → exit visual mode"), Line::from(""), Line::from("COMMANDS (press : then type):"), - Line::from(" :h, :help → show this help"), - Line::from(" :m, :model → select model"), - Line::from(" :n, :new → start new conversation"), - Line::from(" :c, :clear → clear current conversation"), - Line::from(" :q, :quit → quit application"), + Line::from(" :h, :help → show this help"), + Line::from(" :m, :model → select model"), + Line::from(" :n, :new → start new conversation"), + Line::from(" :c, :clear → clear current conversation"), + Line::from(" :save [name] → save current session"), + Line::from(" :load, :sessions → browse/load saved sessions"), + Line::from(" :q, :quit → quit application"), Line::from(""), Line::from("QUICK KEYS:"), Line::from(" q → quit (from normal mode)"), @@ -1173,6 +1175,140 @@ fn render_help(frame: &mut Frame<'_>) { frame.render_widget(paragraph, area); } +fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { + let area = centered_rect(70, 70, frame.area()); + frame.render_widget(Clear, area); + + let sessions = app.saved_sessions(); + + if sessions.is_empty() { + let text = vec![ + Line::from(""), + Line::from("No saved sessions found."), + Line::from(""), + Line::from("Save your current session with :save [name]"), + Line::from(""), + Line::from("Press Esc to close."), + ]; + + let paragraph = Paragraph::new(text) + .block( + Block::default() + .title(Span::styled( + " Saved Sessions ", + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, area); + return; + } + + let items: Vec = sessions + .iter() + .enumerate() + .map(|(idx, session)| { + let name = session + .name + .as_deref() + .unwrap_or("Unnamed session"); + + let created = session.created_at + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let age_hours = (now - created) / 3600; + let age_str = if age_hours < 1 { + "< 1h ago".to_string() + } else if age_hours < 24 { + format!("{}h ago", age_hours) + } else { + format!("{}d ago", age_hours / 24) + }; + + let info = format!( + "{} messages · {} · {}", + session.message_count, + session.model, + age_str + ); + + let is_selected = idx == app.selected_session_index(); + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let info_style = if is_selected { + Style::default().fg(Color::Black).bg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }; + + let desc_style = if is_selected { + Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::ITALIC) + } else { + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC) + }; + + let mut lines = vec![ + Line::from(Span::styled(name, style)), + ]; + + // Add description if available and not empty + if let Some(description) = &session.description { + if !description.is_empty() { + lines.push(Line::from(Span::styled(format!(" \"{}\"", description), desc_style))); + } + } + + // Add metadata line + lines.push(Line::from(Span::styled(format!(" {}", info), info_style))); + + ListItem::new(lines) + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .title(Span::styled( + format!(" Saved Sessions ({}) ", sessions.len()), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ); + + let footer = Paragraph::new(vec![ + Line::from(""), + Line::from("↑/↓ or j/k: Navigate · Enter: Load · d: Delete · Esc: Cancel"), + ]) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(area); + + frame.render_widget(list, layout[0]); + frame.render_widget(footer, layout[1]); +} + fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let vertical = Layout::default() .direction(Direction::Vertical) -- 2.52.0